Compare commits

..

10 Commits

Author SHA1 Message Date
tony cff52b4b9e feat: travel dates + sea-crossing chooser, Tesla in-car polish, Fleet API stub
- 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
2026-05-31 21:38:27 +01:00
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
tony ed64712525 feat: Phase 5 — live streaming trip building via SSE
Grok now drives the trip rendering in real time instead of dumping
the full result after ~90 seconds.

Backend
- GrokHeadlessClient gains a chatStream() async generator that spawns
  grok with --output-format streaming-json (NDJSON of {type,data}
  events), buffers the "text" tokens, and emits partial events as the
  buffer becomes parseable.
- tryPartialJsonParse — lenient JSON repair: walks the buffer once,
  closes structures in stack order, drops in-progress strings and
  dangling keys, returns whatever object is currently consistent.
  Hard-tested with progressive slicing of a multi-stop itinerary.
- New SSE endpoint POST /api/chat/stream with events: open / thinking
  / partial / done / error. Uses res.on('close') + writableEnded as a
  reliable client-disconnect signal (req.on('close') fires in Express
  5 once the body is consumed, which was killing the grok child).

Frontend
- sendMessage swaps to fetch+ReadableStream against /api/chat/stream
  and parses SSE blocks. Each partial event runs a fast synchronous
  normalizePartialItinerary (no Nominatim — drops stops missing
  lat/lng so partial render doesn't block on geocoding).
- The done event runs the full async normalizer for the final pass
  and caches the result per variant.
- Stops, day cards, map markers, polylines, the variant strip, and
  the trip summary all update progressively as Grok writes each stop.

Verified with a London → Edinburgh prompt: 6 partial events landed
across the 76-second stream, with the rail filling in
"Baldock Services" → "+Grantham A1" → "+Premier Inn Newcastle"
→ "+Fort Kinnaird" before the final done event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:01:00 +01:00
tony 0a97ea2006 feat(ui): Phase 4 — variant cache + compare-on-map overlay
Switching between Fastest / Scenic / Cheapest used to require a fresh
Grok call every time (~90s). Now each variant's itinerary + OSRM
legs are cached the moment they're computed, so:

- Switching back to a previously-viewed variant is instant — no Grok
  round-trip, just a state swap. A toast reports "(cached)" so the
  user knows nothing was refetched.
- The variant strip gains a Compare (n) button that lights up once
  at least two variants are cached. Toggling it overlays the other
  cached variants on the map as dashed lines in their tone colour
  (green for Scenic, blue for Cheapest), while the selected variant
  stays solid red on top.
- Map legend swaps to a variant-key list while Compare is active so
  the user can read which dashed line is which.
- The cache is also kept fresh as soon as legs[] finishes computing
  for the active variant — so toggling Compare immediately after a
  fresh plan still shows real routes, not stale ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:27:02 +01:00
tony b61e3510b9 feat(ui): Phase 3c — vehicle selector panel with all Tesla trims
Replace the simple <select> vehicle chip with a proper anchored
glass panel matching Direction B.

- Full Tesla model catalogue (S / 3 / Y / X / Cybertruck) with all
  trims (Standard / Long Range RWD / Long Range AWD / Performance /
  Plaid / Cyberbeast) and per-trim range, peak kW, 0-100, top speed,
  and badges (Most popular, Best range, Performance).
- Vehicle chip in the top bar now shows the model + abbreviated trim
  ("Model Y LR AWD") with range underneath, opens the panel anchored
  to the chip's bounding rect.
- Panel header has a starting-charge slider (10-100%, snaps to 5).
  Each trim shows "X km now" (= range × charge%) in green so the
  user sees the live impact of picking a different trim or charge
  level.
- Model groups collapse / expand independently; the currently-
  selected model expands by default.
- Picking a trim updates the chip everywhere; closes the panel.
- Backdrop click and Esc both dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:05:48 +01:00
tony 7187975ca5 feat(ui): Phase 3b — Customise, Detour, GPX modals
Three new modals wired into the planner with real interactions:

CustomiseStopModal (4 tabs)
- Tab rail (Charger / Overnight / Duration / Things to do / Detour)
  auto-hides tabs that don't apply (e.g. Charger only for chargers,
  Overnight only for hotels, Things to do only when nearby[] exists).
- Charger tab: radio-style picker over stop.chargerOptions with
  kW/stalls/€/network/detour badge per option.
- Overnight tab: lists the current hotel + stop.alternatives as
  swappable options with Choose buttons.
- Duration tab: 48px figure + range slider (10–120 min, snaps to
  presets) with arrive/leave battery % shown live; preset grid
  (Quick top-up / Coffee / Sit-down lunch / Explore / Full charge /
  Skip).
- Things to do tab: checkbox grid over stop.nearby[] for picking what
  you want to do at the stop.
- Apply Changes commits chargeMinutes + durationMin back into the
  itinerary via a new updateStop() handler.

AddDetourOverlay
- Spotlight-style search bar (autofocus, esc to close) with a
  curated POPULAR_DETOURS list (York Minster, Lake District,
  Hadrian's Wall, Beaune town centre, etc.). Insert button adds the
  detour as a new stop in the current day via a new insertDetour()
  handler. The map polyline / leg metrics recompute automatically.

GpxExportModal
- Real downloadable export — GPX (XML), KML (Google Earth), CSV.
- Live preview pane shows the generated file, with line count + KB
  in the footer. Copy puts the file content on the clipboard;
  Download .gpx writes it as a Blob with the right MIME type and
  triggers a real browser download. Filename derived from the trip's
  first/last stop.
- GPX include-toggles for stop notes and nearby places.
- Empty-trip state shows a friendly message instead of an empty pane.

Plumbing
- Modal state lifted into the planner as { kind, stopId? } | null.
- Top bar Export button, the rail's Add stop button, and the bottom
  Add detour link all open the relevant modal.
- The Customise stop button inside the expanded stop card now opens
  the Customise modal for that stop.
- ModalShell handles backdrop, escape-to-close, scroll-clamp, and
  optional header/footer/subtitle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:02:22 +01:00
tony ab457dafe2 feat(ui): Phase 3a — night block, drag-to-reorder, O/D replan
- NightBlock component: dashed-blue panel that appears under the last
  stop of any day (when it's a hotel or supercharger) and before the
  next day starts, showing "Sleep in car at services" or the hotel
  name with a "Hotel options" button that opens the stop's expansion
  to its overnight swap section.
- Drag-to-reorder: every stop card is draggable; dropping on another
  card moves the dragged stop into that position (and into that
  day if you drag across day boundaries). Order numbers are
  renormalised after each drop; the legs + map polylines + day totals
  recompute automatically.
- Origin/Destination editable replan: editing the origin or
  destination input and pressing Enter or blurring sends a "Replan
  the trip from {origin} to {destination}" chat message. Auto-
  populating from the current itinerary does not trigger a replan;
  only real user edits do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:53:32 +01:00
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
tony ece882ea29 feat(ui): port Direction B layout shell (Phase 1)
Implements the layout-shell phase of the Direction B redesign from the
Anthropic Design handoff bundle.

Visual layer:
- Geist font + new --gd-* CSS token palette (panel/border/text scales,
  green/amber/blue/purple accents alongside the red).
- Top bar with brand chip, origin/destination inputs, chat composer
  with chip-based refinements, vehicle selector chip, Export/Share.
- Body split: map-left flex-1, stops rail 540px right (replaces the
  prior chat-sidebar + bottom-timeline layout).
- Stops rail: trip summary header (stops · days · km · drive · charge),
  sticky day headers with date label + per-day km/drive/charge totals,
  icon-led stop cards with colored type-icon tile (charge/sleep/hotel/
  attraction/cafe/viewpoint colour-coded), combo pill, description,
  charge/arrive meta inline.
- Leg row between cards (km · drive time), still computed from real
  OSRM routes.
- Click stop to expand: 4-stat grid (Charge/Arrive/Leave/Cost),
  amenity chip cloud, alternative-swap list (preserving the existing
  delta swap mechanic), Customise/Remove actions.
- Map ↔ rail bridge: clicking a card flies the map to that pin,
  hovering either highlights the other. Pins are custom Leaflet
  divIcons coloured per stop type with active/hover scaling.
- Empty state + quick-prompt buttons in the rail.

Not in Phase 1 (deferred):
- Variant strip (Fastest/Scenic/Cheapest) — needs backend prompt work.
- While-here food/do/see tabs — needs backend nearby[] data.
- Customise / Add-detour / GPX modals.
- Vehicle selector trim panel (still a native select for now).
- Drag-to-reorder, in-trip/compare/shared screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:37:48 +01:00
tony 225cd250a3 feat: leg metrics + swappable alternatives per stop
Timeline now shows the driving distance and duration between every
consecutive pair of stops, taken from real OSRM road routes (not Grok
estimates). Top stat bar and per-day headers also use the live totals
so they update immediately as the itinerary changes.

Grok now returns 1-3 alternative picks for each Supercharger and
hotel stop, each with deltaKm/deltaMin vs the chosen pick and a short
reason explaining the trade-off. The Swap (n) button on each card
expands an inline list of alternatives; clicking one swaps the stop
in place. The previously-chosen stop is kept in the alternatives
list with inverted deltas so the user can swap back. The map +
polylines + stat bar all recompute automatically.

Other tweaks:
- haversine fallback when OSRM is unreachable so legs still show
  approximate metrics in offline / degraded mode.
- Leg geometry storage moves from raw polyline[] to typed Leg[] with
  per-leg distance/duration/fromId/toId.
- Stop schema gains alternatives[]; client normalization filters out
  alternatives missing lat/lng.
- Day cards widened (340 → 360px); timeline pane grew 280 → 340px
  with vertical scroll so swap panels don't clip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:44:54 +01:00
11 changed files with 4327 additions and 407 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.github
.gitea
node_modules
**/node_modules
dist
client/dist
playwright-report
test-results
ui-preview
.vscode
.idea
*.log
.env
.env.*
!.env.example
+44
View File
@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1.6
# Multi-stage build: install + build the client and server, then run a slim
# node image that serves both from one process on $PORT (default 3000).
# ─── Stage 1: deps + build ──────────────────────────────────────────────────
FROM node:22-bookworm-slim AS builder
WORKDIR /app
# Root deps
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
# Client deps
COPY client/package.json client/package-lock.json* ./client/
RUN --mount=type=cache,target=/root/.npm npm --prefix client ci --no-audit --no-fund
# Source
COPY tsconfig.json ./
COPY server ./server
COPY client ./client
# Build client (vite → client/dist) and server (tsc → dist/server)
RUN npm run build
# ─── Stage 2: runtime ───────────────────────────────────────────────────────
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
# Only ship the prod deps + built artefacts
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/client/dist ./client/dist
EXPOSE 3000
CMD ["node", "dist/server/index.js"]
+45
View File
@@ -0,0 +1,45 @@
// Detect Tesla in-car browser + general touch-only large-screen mode.
// Tesla MCU2 reports a "QtCarBrowser" UA fragment; MCU3 reports a more standard
// Chromium UA but with "Tesla" in some firmware. We also honour ?incar=1 for
// testing on a regular browser, and fall back to a touch + landscape-tablet
// heuristic so the optimisations apply to anything that looks like a car dash.
export interface InCarInfo {
/** True if we're inside (or simulating) a Tesla in-car browser. */
isTesla: boolean;
/** True if we should apply the heavyweight "car dash" UX (big text, no blur, tap-friendly). */
isInCar: boolean;
/** Approximate MCU generation hint when detectable. */
mcu: 'mcu2' | 'mcu3' | 'unknown';
}
export function detectInCar(): InCarInfo {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return { isTesla: false, isInCar: false, mcu: 'unknown' };
}
const ua = navigator.userAgent || '';
const isQt = /QtCarBrowser/i.test(ua);
const isTeslaUa = /Tesla/i.test(ua);
const forced = new URLSearchParams(window.location.search).get('incar') === '1';
const isTesla = isQt || isTeslaUa || forced;
// Heuristic touch-only tablet/dash mode: coarse pointer, ≥1200px width, no fine pointer.
const coarse = window.matchMedia?.('(pointer: coarse)').matches ?? false;
const wide = window.innerWidth >= 1200;
const heuristicInCar = coarse && wide;
return {
isTesla,
isInCar: isTesla || forced || heuristicInCar,
mcu: isQt ? 'mcu2' : isTeslaUa ? 'mcu3' : 'unknown',
};
}
/** Apply or remove the body class side effects (idempotent). */
export function applyInCarClass(info: InCarInfo) {
const root = document.documentElement;
root.classList.toggle('incar', info.isInCar);
root.classList.toggle('tesla', info.isTesla);
root.classList.toggle('tesla-mcu2', info.mcu === 'mcu2');
root.classList.toggle('tesla-mcu3', info.mcu === 'mcu3');
}
+3
View File
@@ -4,6 +4,9 @@ import { BrowserRouter } from 'react-router-dom';
import { Toaster } from 'sonner';
import App from './App';
import './styles/globals.css';
import { detectInCar, applyInCarClass } from './lib/incar';
applyInCarClass(detectInCar());
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
File diff suppressed because it is too large Load Diff
+124 -25
View File
@@ -1,51 +1,150 @@
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--tesla-red: #E82127;
--bg: #0a0a0a;
--bg-elevated: #111111;
--bg-card: #1a1f2b;
--border: rgba(255, 255, 255, 0.08);
/* Direction B palette */
--gd-bg: #0a0a0c;
--gd-bg-2: #111114;
--gd-panel: #15151a;
--gd-panel-2: #1c1c22;
--gd-border: rgba(255, 255, 255, 0.08);
--gd-border-2: rgba(255, 255, 255, 0.14);
--gd-text: #f5f5f7;
--gd-text-2: #a8a8b0;
--gd-text-3: #6c6c75;
--gd-red: #e31937;
--gd-red-soft: rgba(227, 25, 55, 0.14);
--gd-red-line: rgba(227, 25, 55, 0.32);
--gd-green: #4ade80;
--gd-amber: #fbbf24;
--gd-blue: #60a5fa;
--gd-purple: #c084fc;
/* Legacy aliases (still referenced in a few places) */
--tesla-red: var(--gd-red);
--bg: var(--gd-bg);
--bg-elevated: var(--gd-bg-2);
--bg-card: var(--gd-panel);
--border: var(--gd-border);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg);
color: white;
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
background: var(--gd-bg);
color: var(--gd-text);
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
}
/* Tesla-inspired scrollbar */
.mono { font-family: 'Geist Mono', ui-monospace, monospace; }
.num { font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; }
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: #3a3a3a;
background: rgba(255, 255, 255, 0.12);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #E82127;
background: rgba(255, 255, 255, 0.22);
}
/* Map container */
.leaflet-container {
background: #0a0f1a !important;
font-family: inherit;
}
/* Chat bubbles */
.chat-bubble-user {
background: #E82127;
color: white;
border-bottom-right-radius: 4px;
/* Override Leaflet popup styling to match Direction B */
.leaflet-popup-content-wrapper {
background: rgba(20, 20, 24, 0.95);
color: var(--gd-text);
border: 1px solid var(--gd-border-2);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
.leaflet-popup-tip {
background: rgba(20, 20, 24, 0.95);
}
.leaflet-popup-content {
margin: 10px 12px;
font-size: 12px;
line-height: 1.5;
}
.chat-bubble-assistant {
background: #1f242e;
border: 1px solid rgba(255,255,255,0.08);
border-bottom-left-radius: 4px;
/* ─── In-car browser overrides ─────────────────────────────────────────────
* Triggered when <html class="incar"> is set by client/src/lib/incar.ts.
* Goals: bigger fonts, larger tap targets, no expensive blurs (MCU1/MCU2
* fall off a cliff with backdrop-filter), no accidental hover-only states.
* Scoped to .incar so it never affects desktop builds.
*/
html.incar {
font-size: 17px; /* baseline bump — most leaf text uses px values
so this primarily affects rem-based things */
}
html.incar body {
/* Slightly thicker base text colour for readability at arm's length */
color: var(--gd-text);
}
/* Kill backdrop-filter entirely — it murders frame rate on MCU2 */
html.incar *,
html.incar *::before,
html.incar *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Bump every interactive control to 44px minimum tap target. */
html.incar button,
html.incar [role="button"],
html.incar input[type="date"],
html.incar input[type="text"],
html.incar input[type="number"] {
min-height: 44px;
font-size: 15px;
}
/* Scale tiny utility text up — Tailwind ships these as fixed px values so
* we override them globally inside .incar. */
html.incar .text-\[10px\],
html.incar .text-\[10\.5px\],
html.incar .text-\[11px\] { font-size: 13px !important; }
html.incar .text-\[11\.5px\],
html.incar .text-\[12px\] { font-size: 14px !important; }
html.incar .text-\[12\.5px\],
html.incar .text-\[13px\],
html.incar .text-\[13\.5px\] { font-size: 15px !important; }
html.incar .text-\[14px\] { font-size: 16px !important; }
html.incar .text-\[15px\] { font-size: 17px !important; }
html.incar .text-\[16px\] { font-size: 18px !important; }
html.incar .text-\[18px\] { font-size: 20px !important; }
html.incar .text-\[20px\] { font-size: 22px !important; }
/* Native date input is a tiny target on touch — fatten it. */
html.incar input[type="date"] {
padding: 12px 14px;
font-size: 16px;
}
/* Bigger scrollbars — finger-friendly */
html.incar ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
/* Hover states cause flicker on touch — disable on the incar build. */
html.incar .hover\:bg-white\/\[0\.04\]:hover,
html.incar .hover\:bg-white\/\[0\.03\]:hover,
html.incar .hover\:bg-white\/\[0\.025\]:hover,
html.incar .hover\:bg-white\/\[0\.02\]:hover {
background: transparent !important;
}
+15
View File
@@ -24,4 +24,19 @@ export const env = {
xaiApiKey: process.env.XAI_API_KEY || '',
grokEnabled: process.env.GROK_ENABLED !== 'false',
forceXaiApi: process.env.FORCE_XAI_API === 'true',
// Tesla Fleet API
tesla: {
// Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem
// for domain verification. Set TESLA_FLEET_PUBLIC_KEY to the PEM contents
// (multi-line; can include literal newlines).
publicKey: process.env.TESLA_FLEET_PUBLIC_KEY || '',
// OAuth client credentials Tesla gives you after partner approval.
clientId: process.env.TESLA_FLEET_CLIENT_ID || '',
clientSecret: process.env.TESLA_FLEET_CLIENT_SECRET || '',
// Where Tesla redirects after the user authorises.
redirectUri: process.env.TESLA_FLEET_REDIRECT_URI || 'https://roadtrip.tony.codes/api/auth/tesla/callback',
// Region: 'eu' or 'na'.
region: (process.env.TESLA_FLEET_REGION || 'eu') as 'eu' | 'na',
},
} as const;
+26
View File
@@ -3,10 +3,14 @@ import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import { env } from './config/env.js';
import { logger } from './lib/logger.js';
import chatRoutes from './routes/chat.js';
import tripsRoutes from './routes/trips.js';
import teslaRoutes from './routes/tesla.js';
import { createOptionalAuth } from './lib/auth.js';
const app = express();
@@ -34,9 +38,31 @@ if (auth) {
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
}
// Tesla integration: serves the partner public key + OAuth callback. Mounted
// at the app root because Tesla's well-known path is fixed.
app.use(teslaRoutes);
app.use('/api', chatRoutes);
app.use('/api/trips', tripsRoutes);
// ─── Static client (production only) ─────────────────────────────────────────
// In dev, Vite serves the client on :5173. In production (Dokku), the built
// client lands in client/dist via `npm run build` and we serve it from here.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const clientDist = path.resolve(__dirname, '../../client/dist');
if (existsSync(clientDist)) {
app.use(express.static(clientDist, { index: false, maxAge: '1h' }));
app.get('*', (req, res, next) => {
// Don't shadow API or well-known paths.
if (req.path.startsWith('/api') || req.path.startsWith('/.well-known')) return next();
res.sendFile(path.join(clientDist, 'index.html'));
});
logger.info({ clientDist }, 'Serving built client');
} else {
logger.info('No client/dist found — relying on Vite dev server');
}
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
logger.error({ err }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error' });
+94 -2
View File
@@ -12,6 +12,14 @@ const ChatRequestSchema = z.object({
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) => {
@@ -27,7 +35,7 @@ router.post('/chat', async (req, res) => {
return res.status(400).json({ error: 'Invalid request' });
}
const { message, vehicle, itinerary, history = [] } = parsed.data;
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
log.info({
requestId,
@@ -35,13 +43,19 @@ router.post('/chat', async (req, res) => {
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
vehicle,
selectedVariant,
{ origin, destination, travelDates },
);
const duration = Date.now() - start;
@@ -50,6 +64,12 @@ router.post('/chat', async (req, res) => {
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,
@@ -71,6 +91,78 @@ router.post('/chat', async (req, res) => {
});
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();
+72
View File
@@ -0,0 +1,72 @@
import { Router } from 'express';
import { env } from '../config/env.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('tesla');
const router = Router();
// ─── Domain verification ────────────────────────────────────────────────────
// Tesla fetches this path to confirm you own the domain registered with the
// Fleet API partner account. The body must be the EXACT PEM the partner key
// is registered with (the EC public key from your prime256v1 keypair).
//
// Set TESLA_FLEET_PUBLIC_KEY in Dokku config to the full PEM contents —
// including the BEGIN/END lines. Multi-line env vars work fine with Dokku
// when set via `dokku config:set roadtrip TESLA_FLEET_PUBLIC_KEY="$(cat key.pem)"`.
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
if (!env.tesla.publicKey) {
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
res.status(404).type('text/plain').send('Public key not configured');
return;
}
res.type('application/x-pem-file');
res.set('Cache-Control', 'public, max-age=300');
res.send(env.tesla.publicKey);
});
// ─── OAuth callback (stub) ──────────────────────────────────────────────────
// Tesla redirects here with ?code=… after the user grants access. We exchange
// the code for a refresh token and store it against the logged-in user.
// Full implementation lands once partner approval is granted.
router.get('/api/auth/tesla/callback', async (req, res) => {
const { code, state, error } = req.query as Record<string, string | undefined>;
if (error) {
log.warn({ error, state }, 'Tesla OAuth error returned to callback');
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
return;
}
if (!code) {
res.status(400).type('text/plain').send('Missing ?code from Tesla');
return;
}
if (!env.tesla.clientId || !env.tesla.clientSecret) {
log.warn('Tesla OAuth callback hit but client credentials not configured');
res.status(503).type('text/plain').send('Tesla integration not yet configured');
return;
}
// TODO once Tesla approves partner registration:
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code
// 2. Decode the id_token, persist refresh_token against req.auth.userId
// 3. Optional: enrol the vehicle via /api/1/partner_accounts/public_key
// 4. Redirect to / with a success flag
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)');
res.redirect('/?tesla_connected=pending');
});
// ─── Vehicle state (stub) ───────────────────────────────────────────────────
// Returns the current battery %, range and location for the connected vehicle.
// Until partner approval, returns 503 so the client can hide the integration UI.
router.get('/api/tesla/state', async (_req, res) => {
if (!env.tesla.clientId) {
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
return;
}
// TODO: look up the user's stored refresh token, exchange for access token,
// call https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/{id}/vehicle_data,
// and shape the response into { battery, range, lat, lng, state, etc. }.
res.status(501).json({ connected: false, reason: 'not_implemented' });
});
export default router;
+483 -19
View File
@@ -15,9 +15,110 @@ const log = createLogger('grok-headless');
const SENTINEL = 'ITINERARY_UPDATE:';
export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; }
export interface GrokResponse { text: string; updatedItinerary?: any; }
export interface GrokResponse { text: string; updatedItinerary?: any; variants?: any[]; selectedVariant?: string; }
export type VehicleInput = string | { name: string; rangeKm?: number };
export interface StreamEvent {
type: 'thinking' | 'partial' | 'done' | 'error';
message?: string;
itinerary?: any;
variants?: any[];
text?: string;
selectedVariant?: string;
error?: string;
}
/**
* Lenient JSON parser — given a truncated/in-progress JSON string,
* balance open brackets/quotes and try to parse. Returns null on failure.
*
* Closes nested structures in correct stack order (innermost first).
*/
export function tryPartialJsonParse(input: string): any | null {
const first = input.indexOf('{');
if (first === -1) return null;
const stripped = input.slice(first).replace(/^```json\s*/, '').replace(/```\s*$/, '');
// Walk the buffer to record open structures and look for a complete top-level object
const stack: ('{' | '[')[] = [];
let inStr = false, escape = false;
let cleanEnd = -1;
let lastSafePos = -1; // last position where we're outside strings + at a "safe" punctuation
for (let i = 0; i < stripped.length; i++) {
const c = stripped[i];
if (escape) { escape = false; continue; }
if (inStr) {
if (c === '\\') { escape = true; continue; }
if (c === '"') { inStr = false; lastSafePos = i; }
continue;
}
if (c === '"') { inStr = true; continue; }
if (c === '{' || c === '[') { stack.push(c as '{' | '['); continue; }
if (c === '}' || c === ']') {
stack.pop();
if (stack.length === 0) cleanEnd = i;
lastSafePos = i;
continue;
}
if (c === ',' || c === ':') { lastSafePos = i; }
}
if (cleanEnd !== -1) {
try { return JSON.parse(stripped.slice(0, cleanEnd + 1)); } catch { /* fall through */ }
}
// Build repaired buffer:
// 1) Truncate to lastSafePos (a comma, colon, quote-close, or bracket-close)
// 2) Strip trailing comma OR a dangling key (e.g. `,"foo"` or `:"foo`)
// 3) Close stack in reverse order
let repaired = stripped;
if (inStr) {
// We're mid-string — chop everything from the opening quote
let q = repaired.length - 1;
while (q >= 0 && repaired[q] !== '"') q--;
if (q >= 0) repaired = repaired.slice(0, q);
inStr = false;
} else if (lastSafePos !== -1 && lastSafePos < repaired.length - 1) {
repaired = repaired.slice(0, lastSafePos + 1);
}
// Trim trailing comma, colon, or partial key/value
// e.g. `"name":` → drop the `:` and the preceding `"name"` (it'd be a key with no value)
for (let pass = 0; pass < 4; pass++) {
const before = repaired.length;
// Trailing colon (incomplete key) — drop the key
repaired = repaired.replace(/,?\s*"[^"]*"\s*:\s*$/, '');
// Trailing comma
repaired = repaired.replace(/,\s*$/, '');
// Dangling identifier (true/false/null/number-ish) after colon — drop
repaired = repaired.replace(/,?\s*"[^"]*"\s*:\s*[a-zA-Z0-9.\-+eE]+$/, '');
if (repaired.length === before) break;
}
// Rebuild the open-stack on the REPAIRED buffer (in case the trims changed it)
const finalStack: ('{' | '[')[] = [];
inStr = false; escape = false;
for (let i = 0; i < repaired.length; i++) {
const c = repaired[i];
if (escape) { escape = false; continue; }
if (inStr) {
if (c === '\\') { escape = true; continue; }
if (c === '"') inStr = false;
continue;
}
if (c === '"') { inStr = true; continue; }
if (c === '{' || c === '[') { finalStack.push(c as '{' | '['); continue; }
if (c === '}') { if (finalStack[finalStack.length - 1] === '{') finalStack.pop(); continue; }
if (c === ']') { if (finalStack[finalStack.length - 1] === '[') finalStack.pop(); continue; }
}
// Close in reverse stack order — '{' → '}', '[' → ']'
for (let i = finalStack.length - 1; i >= 0; i--) {
repaired += finalStack[i] === '{' ? '}' : ']';
}
try { return JSON.parse(repaired); } catch { return null; }
}
function vehicleName(v: VehicleInput): string {
return typeof v === 'string' ? v : v.name;
}
@@ -59,10 +160,29 @@ export class GrokHeadlessClient {
}
}
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput) {
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}) {
const variantBrief = {
fast: 'Fastest — minimise drive time. Pick the most direct route via motorways. Sleep in the car or at a budget hotel with destination charging. Optimise for arriving sooner, not for sightseeing.',
scenic: 'Scenic — pick the prettiest practical route even if it adds time. Favour scenic A-roads, viewpoints, charming towns, regional food. Stay at a hotel (not car-sleep). Add an extra hour or two for memorable stops.',
cheap: 'Cheapest — minimise cost. Avoid toll roads where possible, prefer off-peak charging, pick budget overnight options (car sleep or basic hotels), and choose cheaper chargers when available. Drive time can be a bit longer to save €.',
}[selectedVariant] || 'Fastest — minimise drive time.';
const odBlock = (opts.origin && opts.destination)
? `\nTRIP ENDPOINTS (these are the ground truth — your itinerary MUST start exactly here and end exactly here):\n Origin: ${opts.origin}\n Destination: ${opts.destination}\n`
: '';
const td = opts.travelDates;
const hasDates = !!(td && (td.outbound || td.return));
const datesBlock = hasDates
? `\nTRAVEL DATES (use these for crossing/ferry/hotel pricing — peak vs off-peak vs weekend):\n Outbound: ${td!.outbound || '(not set)'}\n Return: ${td!.return || '(one-way)'}\n Travellers: ${td!.travellers ?? 'unknown'}\n`
: `\nTRAVEL DATES: not yet provided by the user. Use ballpark off-peak prices for now and set "needsTravelDates": true on the itinerary so the UI prompts the user to add dates for accurate pricing.\n`;
return `You are Grok Drive, an expert Tesla road trip planner for the UK and Europe. You build practical, enjoyable itineraries — not just a list of charging stops. Treat every break as a chance to eat, rest, sightsee, or sleep.
Current vehicle: ${vehicleName(vehicle)}
Selected route variant: ${selectedVariant.toUpperCase()}
${variantBrief}
Current vehicle: ${vehicleName(vehicle)}${odBlock}${datesBlock}
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown.
@@ -91,7 +211,66 @@ Respond with **only** a single valid JSON object in exactly this format. No text
"amenities": ["restaurant", "coffee", "toilets", "shopping", "wifi", "playground", "ev-charging", "destination-charging"],
"cuisine": "British pub" | "Italian" | "French" | "Cafe" | null,
"priceLevel": 1 | 2 | 3 | 4,
"notes": "optional extra hint (booking tips, opening hours, etc.)"
"notes": "optional extra hint (booking tips, opening hours, etc.)",
"alternatives": [
{
"id": "unique-alt-string",
"name": "Alternative pick name",
"type": "supercharger" | "hotel" | "restaurant" | "cafe" | "attraction" | "destination-charger" | "viewpoint" | "custom",
"lat": 51.5,
"lng": -0.1,
"description": "1-2 sentences explaining why this is a viable swap",
"combo": "charge + eat" | "stay + destination charging" | null,
"amenities": ["restaurant", "toilets"],
"cuisine": "Italian" | null,
"priceLevel": 2,
"chargeMinutes": 25,
"durationMin": 60,
"deltaKm": 12,
"deltaMin": 9,
"reason": "Short reason this is a worthwhile alternative (e.g. 'Cheaper and faster but no restaurant on site')"
}
],
"nearby": [
{
"category": "food" | "do" | "see" | "shop" | "rest",
"icon": "coffee" | "restaurant" | "fast-food" | "shopping" | "supermarket" | "viewpoint" | "museum" | "park" | "beach" | "playground" | "toilets" | "wifi",
"name": "Boulangerie Pâtisserie L. Marc",
"detail": "3 min walk · 4.7★ · open until 19:00"
}
],
"chargerOptions": [
{
"id": "unique-charger-id",
"name": "Aire de Beaune Supercharger",
"network": "Tesla" | "Ionity" | "Allego" | "TotalEnergies" | "Fastned" | "BP Pulse" | "Other",
"stalls": 12,
"kw": 250,
"pricePerKwh": 0.42,
"detourMin": 0,
"isCurrent": true,
"badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null
}
],
"crossingOptions": [
{
"id": "unique-crossing-id",
"operator": "Eurotunnel Le Shuttle" | "DFDS" | "P&O Ferries" | "Brittany Ferries" | "Stena Line" | "Irish Ferries" | "Other",
"mode": "tunnel" | "ferry",
"fromPort": "Folkestone, UK",
"toPort": "Coquelles (Calais), FR",
"durationMin": 35,
"priceEur": 180,
"frequency": "every 30 min, 24/7",
"pros": ["Fastest", "Drive on/off, no walking"],
"cons": ["Most expensive"],
"badge": "Fastest" | "Cheapest" | "Most scenic" | "Overnight" | "Frequent" | null,
"detourMin": 0,
"detourKm": 0,
"isCurrent": true,
"bookingUrl": "https://www.eurotunnel.com"
}
]
}
]
}
@@ -103,12 +282,52 @@ Respond with **only** a single valid JSON object in exactly this format. No text
"superchargers": 3,
"hotels": 1,
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
},
"needsTravelDates": true
},
"variants": [
{
"id": "fast",
"label": "Fastest",
"tone": "primary",
"distanceKm": 2074,
"driveHours": 23.5,
"chargeHours": 4.5,
"costEur": 312,
"highlight": "drive" | "cost" | "pretty",
"pros": ["8 stops", "Sleep in car · Reims", "1 night", "A26 corridor"]
},
{
"id": "scenic",
"label": "Scenic",
"tone": "green",
"distanceKm": 2218,
"driveHours": 26.2,
"chargeHours": 4.8,
"costEur": 328,
"highlight": "pretty",
"pros": ["Via Burgundy + Pyrénées", "Hotel night · Avignon", "10 stops", "+2h 42m"]
},
{
"id": "cheap",
"label": "Cheapest",
"tone": "blue",
"distanceKm": 2098,
"driveHours": 24.0,
"chargeHours": 5.2,
"costEur": 270,
"highlight": "cost",
"pros": ["Avoids tolls", "Off-peak charging", "€42 cheaper"]
}
}
]
}
Strict route planning rules:
- Plan stops in the actual order the driver will encounter them on the road.
- THE FIRST STOP MUST BE THE EXACT ORIGIN THE USER GAVE. THE LAST STOP MUST BE THE EXACT DESTINATION THE USER GAVE. Never start the trip in a different city, country, port or service station. If the user said "from MK78PJ" or "from Milton Keynes", the very first stop must be at that postcode/town with type "custom" (or "supercharger"/"hotel" if it genuinely is one), real lat/lng, and a name like "Start · Milton Keynes (MK7 8PJ)".
- UK postcodes (e.g. MK7 8PJ, SW1A 1AA, EH1 1YZ) are valid starting points. Geocode them precisely — the first letters are the postal area (MK = Milton Keynes, SW = London SW, EH = Edinburgh). Do not skip the UK leg of the trip just because chargers are sparse there.
- For UK → mainland Europe trips: include the UK departure point (e.g. Folkestone Eurotunnel, Dover ferry, or Hull ferry) and the corresponding mainland arrival point (Calais/Coquelles, Dunkirk, Rotterdam, etc.) as explicit stops. Note the crossing in the description and add a sensible duration. Do NOT begin the itinerary in Calais — that erases the UK side of the journey.
- For mainland Europe → UK trips: same but in reverse.
- Choose Superchargers that are realistically reachable given the vehicle range.
- Space charging stops sensibly (every 150-250km depending on route and battery).
- Calculate realistic estArrivalBattery based on distance driven since last charge.
@@ -117,6 +336,57 @@ Strict route planning rules:
- "message" should feel like a helpful human assistant.
- If no clear trip is requested yet, set "itinerary" to null.
Travel dates & pricing:
- If the user has NOT provided travel dates, set "needsTravelDates": true on the itinerary object. In "message", briefly mention that adding dates will sharpen the crossing/ferry and hotel pricing. Use moderate off-peak prices in the meantime.
- If dates ARE provided, set "needsTravelDates": false and lean prices to the right tier (weekend, school holidays, peak summer, etc.) and mention that in "message" where relevant.
- Hotel prices in "description" can include a rough nightly rate when known ("Premier Inn from £75/night on those dates"). Don't fabricate exact prices for specific rooms.
Route variants (REQUIRED):
- "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order.
- Each variant is a *summary only* — drive/charge/cost/pros — describing what the route would look like if the user picked that variant. The actual stops in "itinerary" reflect the currently-selected variant: "${selectedVariant}".
- "distanceKm" (number, km), "driveHours" (number, decimal hours, e.g. 23.5), "chargeHours" (number, decimal hours), "costEur" (number, € for tolls + charging combined).
- "pros" is 3-5 short pills (max ~30 chars each) that describe the unique selling points of that variant relative to the others (e.g. "Avoids tolls", "Sleep in car · Reims", "+2h 42m drive").
- "highlight" picks the stat to colour-highlight: "drive" for fastest, "pretty" for scenic, "cost" for cheapest.
- The 3 variants must be genuinely different (different stops, different days, different totals). Don't just shuffle the same route.
Nearby (REQUIRED for every Supercharger, destination-charger and hotel stop):
- Populate "nearby" with 3-6 places within walking distance of the stop.
- Categories: "food" (restaurants/cafes/bakeries), "do" (walks, things to do), "see" (sights/viewpoints/museums), "shop" (supermarkets, retail), "rest" (toilets, lounges).
- "detail" should include walk time and a quick descriptor or rating (e.g. "3 min walk · 4.5★ · paella", "8 min · UNESCO ruins").
- "icon" should be one of the amenity tokens (coffee, restaurant, fast-food, shopping, supermarket, viewpoint, museum, park, beach, playground, toilets, wifi).
- These are real places at or near the stop — pick named establishments where possible.
Charger options (REQUIRED for every Supercharger and destination-charger stop):
- "chargerOptions" must list 1-4 real charging operators in the immediate area of this stop. The current pick is duplicated as the first entry with isCurrent: true.
- "network" must be the real charging network (Tesla / Ionity / Allego / TotalEnergies / Fastned / BP Pulse / Other).
- "stalls" is the total number of charging stalls at that location, "kw" is the max charging power, "pricePerKwh" is the public €/kWh price.
- "detourMin" is the extra drive time vs the currently-chosen charger (0 for the current pick).
- "badge" can be "Faster" (higher kW), "Cheaper" (lower €/kWh), "Newer", "More stalls", or null. Pick one based on the trade-off vs the current pick.
- This lets the user swap to a faster but pricier Ionity, or a cheaper Allego, etc.
Sea crossings (REQUIRED for every UK ↔ mainland Europe trip, and any other route that crosses water):
- When the route includes a Channel crossing or any other sea/tunnel crossing, insert a dedicated stop with type "crossing" (use "tunnel" for Eurotunnel, "ferry" for ferries) at the appropriate point in the day's stops.
- That crossing stop MUST populate "crossingOptions" with 3-5 genuinely different real-world options the user could pick from. The currently-chosen one is duplicated as the first entry with isCurrent: true.
- For UK ↔ France: at minimum include Eurotunnel Le Shuttle (Folkestone→Coquelles), DFDS Dover→Calais, P&O Dover→Calais (or Dover→Dunkirk), and at least one longer/scenic option (DFDS Newhaven→Dieppe, Brittany Ferries Portsmouth→Caen/Le Havre/Cherbourg, or Brittany Ferries Plymouth→Roscoff) when they make geographic sense for the route.
- For UK ↔ Netherlands/Belgium: include P&O Hull→Rotterdam, Stena Line Harwich→Hook of Holland, DFDS Newcastle→Amsterdam where appropriate.
- For UK ↔ Ireland: include Irish Ferries / Stena Line Holyhead→Dublin, Liverpool→Belfast/Dublin, Fishguard→Rosslare etc.
- "priceEur" should reflect a realistic ballpark for a Tesla with the given travellers. If travel dates are provided, lean toward the right pricing tier (weekday off-peak vs weekend peak vs school holidays). If no dates yet, use moderate off-peak pricing.
- "durationMin" is the crossing time itself (35 min for Eurotunnel, ~90 min for Dover-Calais ferry, ~14 h for an overnight Hull-Rotterdam, etc.).
- "frequency" is a one-line text description of departure cadence (e.g. "every 30 min, 24/7", "8 sailings/day", "1 overnight sailing/day").
- "badge" picks the trade-off: "Fastest" for Eurotunnel, "Cheapest" for the cheapest sensible ferry, "Most scenic" for routes that swap a few hours of UK driving for a longer but prettier crossing (e.g. Portsmouth→Caen), "Overnight" for sleeper ferries (frees up a hotel night), "Frequent" for high-cadence operators.
- "detourMin" and "detourKm" express the change in TOTAL trip distance + drive time vs the current chosen crossing (positive = adds, negative = saves). E.g. Portsmouth→Caen vs Dover→Calais might save 200 km of French driving but add 150 km of UK driving and a 6 h crossing.
- Always set a sensible "bookingUrl" (operator's main booking page).
- If the user already picked a crossing, keep its choice as isCurrent: true and adjust the rest of the itinerary's routing accordingly.
Alternatives (REQUIRED for every Supercharger and hotel stop):
- For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer.
- Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description.
- "deltaKm" is the estimated change in total trip distance vs the chosen stop (positive = adds km, negative = saves km).
- "deltaMin" is the estimated change in total drive time vs the chosen stop, in minutes.
- "reason" explains the trade-off in one short sentence ("Cheaper hotel, no destination charging" / "Adds 15 mins but has the best food on this stretch of the M6").
- Alternatives must be genuinely different choices a driver would consider — not minor variants. Mix the trade-offs: faster, cheaper, fancier, better food, closer to attractions, etc.
- For non-Supercharger/non-hotel stops (a viewpoint, a quick coffee), alternatives are optional.
Combo philosophy (THIS IS THE IMPORTANT PART — don't skip):
- Whenever possible, pick Superchargers that are co-located with a real restaurant, cafe, services area, supermarket, or visitor attraction. Mention what's there in "description" and tag the stop with combo: "charge + eat" (or similar).
- Prefer hotels that offer destination charging (Tesla destination chargers, Type 2, or onsite EV charging). Tag those combo: "stay + destination charging" and add "destination-charging" to amenities.
@@ -138,21 +408,21 @@ ${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
Respond with ONLY the JSON object.`;
}
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput): Promise<GrokResponse> {
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length }, '=== NEW CHAT REQUEST ===');
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ===');
const activeProvider = await this.getActiveProvider(requestId);
if (activeProvider === 'xai') {
return this.callXaiApi(messages, itinerary, vehicle, requestId);
return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts);
}
if (activeProvider === 'fallback') {
return this.dumbFallback(messages, requestId);
}
// LOCAL PERSONAL GROK CLI
const prompt = this.buildPrompt(messages, itinerary, vehicle);
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-'));
const disallowed = env.nodeEnv === 'development'
@@ -196,14 +466,14 @@ Respond with ONLY the JSON object.`;
const data = JSON.parse(result) as { text?: string };
const rawText = data.text || '';
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText);
log.info({ requestId, hasItinerary: !!parsed }, 'Local Grok CLI returned JSON response');
return { text: cleanText, updatedItinerary: parsed };
const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText);
log.info({ requestId, hasItinerary: !!parsed, variantCount: variants?.length || 0 }, 'Local Grok CLI returned JSON response');
return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant };
} catch (err) {
log.error({ requestId, err: String(err) }, 'Local authenticated Grok CLI failed — falling back to xAI API');
if (env.xaiApiKey) {
return this.callXaiApi(messages, itinerary, vehicle, requestId);
return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts);
}
return this.dumbFallback(messages, requestId);
} finally {
@@ -211,8 +481,201 @@ Respond with ONLY the JSON object.`;
}
}
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle);
/**
* Streaming chat — yields incremental partial itineraries as Grok produces output.
* Falls back to non-streaming if local CLI is unavailable.
*/
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): AsyncGenerator<StreamEvent> {
const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
const activeProvider = await this.getActiveProvider(requestId);
if (activeProvider !== 'local') {
// No real streaming for xAI/fallback yet — just do the regular call and emit a single done event
yield { type: 'thinking', message: 'Asking Grok…' };
const result = activeProvider === 'xai'
? await this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts)
: await this.dumbFallback(messages, requestId);
yield {
type: 'done',
text: result.text,
itinerary: result.updatedItinerary,
variants: result.variants,
selectedVariant: result.selectedVariant ?? selectedVariant,
};
return;
}
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-stream-'));
const disallowed = env.nodeEnv === 'development'
? 'search_replace,write_file,Agent,run_terminal_cmd'
: 'run_terminal_cmd,search_replace,write_file,Agent';
const args = [
'-p', prompt,
'--output-format', 'streaming-json',
'--yolo',
'--disallowed-tools', disallowed,
'--tools', 'web_search,web_fetch',
'--max-turns', '6',
'--cwd', tmp,
];
log.info({ requestId }, 'Spawning grok with streaming-json output');
const child = spawn(env.grokBin, args, {
cwd: tmp,
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
type LineEvent = { type: string; data?: string; message?: string };
const lineQueue: LineEvent[] = [];
const errorChunks: string[] = [];
let lineBuffer = '';
let textBuffer = '';
let lastParseLen = 0;
let lastEmittedStops = 0;
let lastEmittedDays = 0;
let thoughtBuffer = '';
let lastEmittedThought = '';
let lastEmittedThoughtLen = 0;
let closed = false;
let closeCode: number | null = null;
let waker: (() => void) | null = null;
const pushLine = (raw: string) => {
if (!raw) return;
try {
const ev = JSON.parse(raw) as LineEvent;
lineQueue.push(ev);
if (waker) { const w = waker; waker = null; w(); }
} catch (e) {
log.warn({ requestId, raw: raw.slice(0, 200) }, 'Failed to parse grok stream line');
}
};
child.stdout.on('data', (chunk: Buffer) => {
lineBuffer += chunk.toString('utf8');
let nl: number;
while ((nl = lineBuffer.indexOf('\n')) !== -1) {
const line = lineBuffer.slice(0, nl).trim();
lineBuffer = lineBuffer.slice(nl + 1);
if (line) pushLine(line);
}
});
child.stderr.on('data', (chunk: Buffer) => { errorChunks.push(chunk.toString('utf8')); });
child.on('error', (err) => {
log.error({ requestId, err: String(err) }, 'grok child spawn error');
errorChunks.push(`spawn error: ${err}`);
closed = true;
closeCode = -1;
if (waker) { const w = waker; waker = null; w(); }
});
child.on('close', (code, signal) => {
if (lineBuffer.trim()) pushLine(lineBuffer.trim());
log.info({ requestId, code, signal, partialsEmitted: lastEmittedStops > 0 ? `${lastEmittedStops} stops` : 'none', bufferLen: textBuffer.length }, 'grok stream complete');
closed = true;
closeCode = code ?? 0;
if (waker) { const w = waker; waker = null; w(); }
});
const waitForLine = () => new Promise<void>((resolve) => {
if (lineQueue.length > 0 || closed) return resolve();
waker = resolve;
});
try {
yield { type: 'thinking', message: 'Connected to Grok — composing itinerary…' };
while (true) {
if (lineQueue.length === 0) {
if (closed) break;
await waitForLine();
continue;
}
const ev = lineQueue.shift()!;
if (ev.type === 'text' && typeof ev.data === 'string') {
textBuffer += ev.data;
// Parse every ~120 chars to keep CPU sane while still catching new stops fast
if (textBuffer.length - lastParseLen > 120) {
lastParseLen = textBuffer.length;
const partial = tryPartialJsonParse(textBuffer);
if (partial && partial.itinerary && Array.isArray(partial.itinerary.days)) {
const stopCount = partial.itinerary.days.reduce(
(sum: number, d: any) => sum + (Array.isArray(d?.stops)
? d.stops.filter((s: any) => s && typeof s.name === 'string' && typeof s.lat === 'number' && typeof s.lng === 'number').length
: 0),
0,
);
const dayCount = partial.itinerary.days.length;
if (stopCount > lastEmittedStops || dayCount > lastEmittedDays) {
log.debug({ requestId, stopCount, dayCount, bufLen: textBuffer.length }, 'emitting partial');
lastEmittedStops = stopCount;
lastEmittedDays = dayCount;
yield {
type: 'partial',
itinerary: partial.itinerary,
variants: Array.isArray(partial.variants) ? partial.variants : undefined,
message: partial.message,
};
}
}
}
} else if (ev.type === 'thought' && typeof ev.data === 'string') {
thoughtBuffer += ev.data;
// Emit on newline boundaries or when we've buffered enough; show the last
// line trimmed and capped so the UI gets a steady stream of short snippets.
const lastNewline = thoughtBuffer.lastIndexOf('\n');
const shouldEmit = lastNewline >= 0 || thoughtBuffer.length - lastEmittedThoughtLen > 200;
if (shouldEmit) {
const tail = lastNewline >= 0 ? thoughtBuffer.slice(lastNewline + 1) : thoughtBuffer;
const snippet = (tail || thoughtBuffer).trim().replace(/\s+/g, ' ').slice(-220);
if (snippet && snippet !== lastEmittedThought) {
lastEmittedThought = snippet;
lastEmittedThoughtLen = thoughtBuffer.length;
yield { type: 'thinking', message: snippet };
}
if (lastNewline >= 0) thoughtBuffer = thoughtBuffer.slice(lastNewline + 1);
}
} else if (ev.type === 'error') {
log.error({ requestId, msg: ev.message }, 'grok streaming error event');
yield { type: 'error', error: ev.message || 'Grok stream error' };
}
}
if (closeCode !== 0) {
log.error({ requestId, closeCode, stderr: errorChunks.join('').slice(-400) }, 'grok stream exited non-zero');
yield { type: 'error', error: `grok exited with code ${closeCode}` };
return;
}
const final = this.parseGrokResponse(textBuffer);
yield {
type: 'done',
text: final.text,
itinerary: final.itinerary,
variants: final.variants,
selectedVariant,
};
} catch (err) {
log.error({ requestId, err: String(err) }, 'grok stream crashed');
yield { type: 'error', error: String(err) };
} finally {
try { if (!child.killed) child.kill(); } catch { /* ignore */ }
await rm(tmp, { recursive: true, force: true }).catch(() => {});
}
}
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');
try {
@@ -238,15 +701,15 @@ Respond with ONLY the JSON object.`;
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const rawText = data.choices?.[0]?.message?.content || '';
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText);
return { text: cleanText, updatedItinerary: parsed };
const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText);
return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant };
} catch (err) {
log.error({ requestId, err }, 'xAI API call failed');
return this.dumbFallback(messages, requestId);
}
}
private parseGrokResponse(rawText: string): { text: string; itinerary: any | null } {
private parseGrokResponse(rawText: string): { text: string; itinerary: any | null; variants?: any[] } {
try {
const cleaned = rawText.trim().replace(/^```json\s*/, '').replace(/```$/, '').trim();
const parsed = JSON.parse(cleaned);
@@ -255,6 +718,7 @@ Respond with ONLY the JSON object.`;
return {
text: parsed.message || parsed.reply || '',
itinerary: parsed.itinerary || null,
variants: Array.isArray(parsed.variants) ? parsed.variants : undefined,
};
}
} catch (e) {
@@ -265,7 +729,7 @@ Respond with ONLY the JSON object.`;
return this.extractItineraryUpdate(rawText);
}
private extractItineraryUpdate(text: string): { text: string; itinerary: any | null } {
private extractItineraryUpdate(text: string): { text: string; itinerary: any | null; variants?: any[] } {
const upperText = text.toUpperCase();
const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase());
if (sentinelIndex === -1) return { text: text.trim(), itinerary: null };