Compare commits
10 Commits
89b24d4c34
...
cff52b4b9e
| Author | SHA1 | Date | |
|---|---|---|---|
| cff52b4b9e | |||
| 88fc86dc32 | |||
| ed64712525 | |||
| 0a97ea2006 | |||
| b61e3510b9 | |||
| 7187975ca5 | |||
| ab457dafe2 | |||
| f63af36451 | |||
| ece882ea29 | |||
| 225cd250a3 |
@@ -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
@@ -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"]
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+3378
-334
File diff suppressed because it is too large
Load Diff
+124
-25
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user