Compare commits
27 Commits
89b24d4c34
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ff39601303 | |||
| 9f489d1fb6 | |||
| 36118b4a4f | |||
| 90a4459e63 | |||
| 10235aaa32 | |||
| b125f9a723 | |||
| a90c8a9354 | |||
| 381eb18cd3 | |||
| 7265103573 | |||
| 25d2779c39 | |||
| 5b97f5b873 | |||
| f793b526aa | |||
| d705669dda | |||
| d27381cae3 | |||
| 58074a0afb | |||
| 8913d2f341 | |||
| fbea75fac6 | |||
| cff52b4b9e | |||
| 88fc86dc32 | |||
| ed64712525 | |||
| 0a97ea2006 | |||
| b61e3510b9 | |||
| 7187975ca5 | |||
| ab457dafe2 | |||
| f63af36451 | |||
| ece882ea29 | |||
| 225cd250a3 |
Binary file not shown.
@@ -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 npm ci --no-audit --no-fund --ignore-scripts
|
||||
|
||||
# Client deps
|
||||
COPY client/package.json client/package-lock.json* ./client/
|
||||
RUN npm --prefix client ci --no-audit --no-fund --ignore-scripts
|
||||
|
||||
# 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 npm ci --omit=dev --no-audit --no-fund --ignore-scripts \
|
||||
&& 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"]
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/tesla-icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<meta name="theme-color" content="#0a0a0c" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tesla Trip Planner • Grok</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
|
||||
Generated
+4334
-89
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tonycodes/auth-react": "^1.4.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"framer-motion": "^11.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.303.0",
|
||||
@@ -20,10 +21,10 @@
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"sonner": "^1.4.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"dompurify": "^3.2.4"
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/leaflet": "^1.9.14",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.47",
|
||||
@@ -34,6 +35,7 @@
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.8",
|
||||
"@types/dompurify": "^3.0.5"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#e31937"/>
|
||||
<path d="M9 21l5.5-10h3L23 21l-2.7-1.5L17 14l-3.5 5.5L9 21z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 200 B |
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { detectInCar } from '../lib/incar';
|
||||
|
||||
type Kind = 'offline-ready' | 'needs-refresh';
|
||||
|
||||
export function SwNotifier() {
|
||||
const [event, setEvent] = useState<Kind | null>(null);
|
||||
const [updateSW, setUpdateSW] = useState<((reload?: boolean) => void) | null>(null);
|
||||
const inCar = detectInCar().isInCar;
|
||||
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
const fn = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() { setEvent('needs-refresh'); },
|
||||
onOfflineReady() { setEvent('offline-ready'); },
|
||||
});
|
||||
setUpdateSW(() => fn);
|
||||
}, []);
|
||||
|
||||
// Desktop / small-screen UX: use a normal toast.
|
||||
useEffect(() => {
|
||||
if (!event || inCar) return;
|
||||
if (event === 'offline-ready') {
|
||||
toast.success('Ready to use offline', {
|
||||
description: 'Your planned trip + map tiles will be available without signal.',
|
||||
});
|
||||
setEvent(null);
|
||||
} else if (event === 'needs-refresh') {
|
||||
toast('New version available', {
|
||||
action: { label: 'Reload', onClick: () => updateSW?.(true) },
|
||||
duration: Infinity,
|
||||
});
|
||||
setEvent(null);
|
||||
}
|
||||
}, [event, inCar, updateSW]);
|
||||
|
||||
if (!inCar || !event) return null;
|
||||
|
||||
// In-car: render a big full-screen acknowledgement with finger-friendly button.
|
||||
const isUpdate = event === 'needs-refresh';
|
||||
const dismiss = () => setEvent(null);
|
||||
const action = () => {
|
||||
if (isUpdate) updateSW?.(true);
|
||||
else dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={dismiss}
|
||||
className="fixed inset-0 grid place-items-center p-8"
|
||||
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.82)' }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-[560px] overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--gd-bg-2)',
|
||||
border: '1px solid var(--gd-border-2)',
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
|
||||
}}
|
||||
>
|
||||
<div className="px-8 py-7" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||
<div className="text-[12px] uppercase tracking-[0.2em] mb-3"
|
||||
style={{ color: isUpdate ? 'var(--gd-amber)' : 'var(--gd-green)' }}>
|
||||
{isUpdate ? 'New version available' : 'Ready to use offline'}
|
||||
</div>
|
||||
<div className="text-[22px] font-semibold leading-[1.25]">
|
||||
{isUpdate
|
||||
? 'Reload to get the latest planner update'
|
||||
: 'Your trip will be available without signal'}
|
||||
</div>
|
||||
<div className="text-[14px] mt-3" style={{ color: 'var(--gd-text-2)' }}>
|
||||
{isUpdate
|
||||
? 'Your current plan stays the same. We just refresh the app.'
|
||||
: 'Planned route, map tiles, and last-known itinerary are saved on this device. You can keep using the planner if LTE drops.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-5 flex items-center gap-3" style={{ borderTop: '1px solid var(--gd-border)' }}>
|
||||
{isUpdate && (
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="flex-1 h-14 text-[16px] rounded-2xl"
|
||||
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={action}
|
||||
className="flex-[2] h-14 text-[16px] rounded-2xl font-semibold"
|
||||
style={{ background: 'var(--gd-red)', color: '#fff', boxShadow: '0 6px 24px rgba(227,25,55,0.35)' }}
|
||||
>
|
||||
{isUpdate ? 'Reload' : 'Got it'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Simple charge-aware battery projection through an itinerary.
|
||||
//
|
||||
// Inputs:
|
||||
// - startSoC starting battery % (Tesla live SoC if connected, else 80)
|
||||
// - effectiveRangeKm the realistic full-charge range (~85% of rated)
|
||||
// - stops + legs in planned order
|
||||
//
|
||||
// Output: per-stop { arriveSoC, leaveSoC, risk }, plus a flag if any leg
|
||||
// would arrive below the danger threshold.
|
||||
|
||||
// Structural shapes — we only need the fields we use, so we don't drag the
|
||||
// full Stop/Leg types out of TeslaTripPlanner.
|
||||
interface BPStop {
|
||||
id: string;
|
||||
type: string;
|
||||
chargeMinutes?: number;
|
||||
}
|
||||
interface BPLeg {
|
||||
fromId: string;
|
||||
distanceKm: number | null;
|
||||
}
|
||||
|
||||
export type BatteryRisk = 'ok' | 'warn' | 'danger';
|
||||
|
||||
export interface BatteryAtStop {
|
||||
stopId: string;
|
||||
arriveSoC: number; // % on arrival (can dip negative if range exhausted)
|
||||
leaveSoC: number; // % after planned charging
|
||||
risk: BatteryRisk;
|
||||
}
|
||||
|
||||
export interface BatteryPlan {
|
||||
perStop: BatteryAtStop[];
|
||||
hasDanger: boolean; // any stop with arriveSoC < DANGER_THRESHOLD
|
||||
hasWarning: boolean; // any stop with arriveSoC < WARN_THRESHOLD
|
||||
}
|
||||
|
||||
const WARN_THRESHOLD = 15;
|
||||
const DANGER_THRESHOLD = 5;
|
||||
|
||||
// Rough charge rates per stop type. Realistic, conservative.
|
||||
function chargeRatePctPerMin(type: string): number {
|
||||
switch (type) {
|
||||
case 'supercharger': return 1.5; // ~250 kW peak, average ~1.5%/min
|
||||
case 'destination-charger': return 0.4; // 11-22 kW
|
||||
case 'hotel': return 0.4; // assume destination charging
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeBatteryPlan(
|
||||
stops: BPStop[],
|
||||
legs: BPLeg[],
|
||||
startSoC: number,
|
||||
effectiveRangeKm: number,
|
||||
): BatteryPlan {
|
||||
const legByFromId = new Map<string, BPLeg>();
|
||||
for (const l of legs) legByFromId.set(l.fromId, l);
|
||||
|
||||
const perStop: BatteryAtStop[] = [];
|
||||
let current = startSoC;
|
||||
let hasDanger = false;
|
||||
let hasWarning = false;
|
||||
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const stop = stops[i]!;
|
||||
let arrive = current;
|
||||
if (i > 0) {
|
||||
const prev = stops[i - 1]!;
|
||||
const leg = legByFromId.get(prev.id);
|
||||
const km = leg?.distanceKm ?? 0;
|
||||
const drop = effectiveRangeKm > 0 ? (km / effectiveRangeKm) * 100 : 0;
|
||||
arrive = current - drop;
|
||||
}
|
||||
|
||||
const risk: BatteryRisk = arrive < DANGER_THRESHOLD ? 'danger'
|
||||
: arrive < WARN_THRESHOLD ? 'warn'
|
||||
: 'ok';
|
||||
if (risk === 'danger') hasDanger = true;
|
||||
if (risk !== 'ok') hasWarning = true;
|
||||
|
||||
// Apply planned charging at this stop. Cap at 100, ignore if no charge planned.
|
||||
const chargeMin = stop.chargeMinutes ?? 0;
|
||||
const rate = chargeRatePctPerMin(stop.type);
|
||||
const leave = Math.min(100, Math.max(arrive, arrive + chargeMin * rate));
|
||||
|
||||
perStop.push({ stopId: stop.id, arriveSoC: arrive, leaveSoC: leave, risk });
|
||||
current = leave;
|
||||
}
|
||||
|
||||
return { perStop, hasDanger, hasWarning };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isMockEnabled, getMockStatus, getMockState } from './teslaMock';
|
||||
|
||||
export interface TeslaStatus {
|
||||
available: boolean;
|
||||
connected: boolean;
|
||||
ownerAuthenticated?: boolean;
|
||||
connectedAt?: number | null;
|
||||
vehicleId?: string | null;
|
||||
vin?: string | null;
|
||||
carType?: string | null;
|
||||
trimBadging?: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface OwnerStatus {
|
||||
authenticated: boolean;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export async function fetchOwnerStatus(): Promise<OwnerStatus> {
|
||||
if (isMockEnabled()) return { authenticated: true, required: true };
|
||||
const res = await fetch('/api/auth/owner/status');
|
||||
if (!res.ok) return { authenticated: false, required: false };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function loginOwner(secret: string): Promise<boolean> {
|
||||
const res = await fetch('/api/auth/owner', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function logoutOwner(): Promise<void> {
|
||||
await fetch('/api/auth/owner/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export interface OwnerPairCode {
|
||||
code: string;
|
||||
expiresInS: number;
|
||||
}
|
||||
|
||||
export async function mintOwnerPairCode(): Promise<OwnerPairCode | null> {
|
||||
const res = await fetch('/api/auth/owner/pair-code', { method: 'POST' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.code) return null;
|
||||
return { code: data.code, expiresInS: data.expiresInS ?? 300 };
|
||||
}
|
||||
|
||||
export async function redeemOwnerPairCode(code: string): Promise<boolean> {
|
||||
const res = await fetch('/api/auth/owner/pair-redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export interface TeslaActiveRoute {
|
||||
destination: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
kmToArrival: number | null;
|
||||
minutesToArrival: number | null;
|
||||
trafficMinutesDelay: number;
|
||||
}
|
||||
|
||||
export interface TeslaState {
|
||||
connected: boolean;
|
||||
asleep?: boolean;
|
||||
battery: number | null;
|
||||
rangeKm: number | null;
|
||||
idealRangeKm?: number | null;
|
||||
chargingState: string | null;
|
||||
chargerPowerKw: number | null;
|
||||
timeToFullCharge: number | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
heading: number | null;
|
||||
speedKmh: number | null;
|
||||
shiftState: 'P' | 'R' | 'N' | 'D' | null;
|
||||
odometerKm?: number | null;
|
||||
carType?: string | null;
|
||||
trimBadging?: string | null;
|
||||
vin?: string | null;
|
||||
vehicleName?: string | null;
|
||||
softwareVersion?: string | null;
|
||||
activeRoute?: TeslaActiveRoute | null;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export async function fetchTeslaStatus(): Promise<TeslaStatus> {
|
||||
if (isMockEnabled()) return getMockStatus();
|
||||
const res = await fetch('/api/tesla/status');
|
||||
if (!res.ok) return { available: false, connected: false };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTeslaState(): Promise<TeslaState | null> {
|
||||
if (isMockEnabled()) return getMockState();
|
||||
const res = await fetch('/api/tesla/state');
|
||||
if (res.status === 202) return null; // asleep
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function wakeTesla(): Promise<boolean> {
|
||||
if (isMockEnabled()) return true;
|
||||
const res = await fetch('/api/tesla/wake', { method: 'POST' });
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function startTeslaConnect(): Promise<void> {
|
||||
if (isMockEnabled()) {
|
||||
window.location.href = `${window.location.pathname}?tesla_connected=1`;
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/auth/tesla/start');
|
||||
if (!res.ok) throw new Error('Could not start Tesla OAuth');
|
||||
const data = await res.json();
|
||||
window.location.href = data.authorizeUrl;
|
||||
}
|
||||
|
||||
export async function disconnectTesla(): Promise<void> {
|
||||
if (isMockEnabled()) return;
|
||||
await fetch('/api/tesla/disconnect', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function sendToTeslaNav(args: { lat: number; lng: number; name?: string }): Promise<boolean> {
|
||||
if (isMockEnabled()) {
|
||||
console.log('[mock] sendToTeslaNav', args);
|
||||
return true;
|
||||
}
|
||||
const res = await fetch('/api/tesla/send-to-nav', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
/** Hook: subscribe to Tesla status + live state. Polls every 60s while connected. */
|
||||
export function useTesla() {
|
||||
const [status, setStatus] = useState<TeslaStatus | null>(null);
|
||||
const [owner, setOwner] = useState<OwnerStatus | null>(null);
|
||||
const [state, setState] = useState<TeslaState | null>(null);
|
||||
const [stateLoading, setStateLoading] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
|
||||
if (!cancelled) { setStatus(s); setOwner(o); }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Live state polling. Only runs when the user is owner-authenticated AND
|
||||
// the Tesla account is connected — anything else returns 401 and the poll
|
||||
// would spam the log.
|
||||
useEffect(() => {
|
||||
if (!status?.connected || !owner?.authenticated) {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
const tick = async () => {
|
||||
setStateLoading(true);
|
||||
try {
|
||||
const s = await fetchTeslaState();
|
||||
setState(s);
|
||||
} finally {
|
||||
setStateLoading(false);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
// Poll faster when mocking so the driving scenario animates visibly.
|
||||
pollRef.current = setInterval(tick, isMockEnabled() ? 3_000 : 60_000);
|
||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
||||
}, [status?.connected, owner?.authenticated]);
|
||||
|
||||
const refreshStatus = async () => {
|
||||
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
|
||||
setStatus(s); setOwner(o);
|
||||
};
|
||||
|
||||
return { status, state, stateLoading, owner, refreshStatus };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Client-side mock for the Tesla integration so we can iterate on UI without
|
||||
// burning Fleet API calls (or sitting in the car).
|
||||
//
|
||||
// Activate via URL:
|
||||
// ?mockTesla=parked parked, 65% battery, in Milton Keynes
|
||||
// ?mockTesla=driving moving at 110 km/h, battery slowly draining
|
||||
// ?mockTesla=charging plugged in at a Supercharger, 132 kW
|
||||
// ?mockTesla=asleep connected but car asleep (state returns null)
|
||||
// ?mockTesla=off disable mock (delete the stored flag)
|
||||
//
|
||||
// The flag persists in localStorage so reloads keep the scenario.
|
||||
// Dev-only: import.meta.env.PROD short-circuits this off in the bundled build,
|
||||
// so production users can't enable mock mode.
|
||||
|
||||
import type { TeslaStatus, TeslaState } from './tesla';
|
||||
|
||||
export type MockScenario = 'parked' | 'driving' | 'charging' | 'asleep';
|
||||
|
||||
const STORAGE_KEY = 'mockTesla';
|
||||
|
||||
export function isMockEnabled(): boolean {
|
||||
return !!getMockScenario();
|
||||
}
|
||||
|
||||
export function getMockScenario(): MockScenario | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// Mock is allowed in production too: it only paints the UI with fake data,
|
||||
// it never reaches the (owner-gated) Fleet API. The visible MOCK chip in
|
||||
// the top bar makes the state obvious.
|
||||
|
||||
const urlParam = new URLSearchParams(window.location.search).get('mockTesla');
|
||||
if (urlParam === 'off') {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
if (urlParam && isValidScenario(urlParam)) {
|
||||
window.localStorage.setItem(STORAGE_KEY, urlParam);
|
||||
return urlParam;
|
||||
}
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored && isValidScenario(stored) ? stored : null;
|
||||
}
|
||||
|
||||
function isValidScenario(s: string): s is MockScenario {
|
||||
return s === 'parked' || s === 'driving' || s === 'charging' || s === 'asleep';
|
||||
}
|
||||
|
||||
export function setMockScenario(s: MockScenario | null): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (s == null) window.localStorage.removeItem(STORAGE_KEY);
|
||||
else window.localStorage.setItem(STORAGE_KEY, s);
|
||||
}
|
||||
|
||||
// ─── Canned vehicle identity ─────────────────────────────────────────────────
|
||||
const MOCK_VEHICLE = {
|
||||
vin: '5YJ3E1EA1KF0MOCKED',
|
||||
carType: 'modely',
|
||||
trimBadging: 'lrawd',
|
||||
vehicleName: 'Mocky',
|
||||
softwareVersion: '2026.5.14',
|
||||
};
|
||||
|
||||
// Base location: Milton Keynes, MK7 8PJ
|
||||
const BASE_LAT = 52.0186;
|
||||
const BASE_LNG = -0.7064;
|
||||
|
||||
// Drift state for the "driving" scenario — battery slowly drains, position
|
||||
// shuffles a bit so map markers move.
|
||||
let driveBattery = 64;
|
||||
let driveTickCount = 0;
|
||||
|
||||
export function getMockStatus(): TeslaStatus {
|
||||
const s = getMockScenario();
|
||||
if (!s) return { available: true, connected: false, ownerAuthenticated: true };
|
||||
return {
|
||||
available: true,
|
||||
connected: true,
|
||||
ownerAuthenticated: true,
|
||||
connectedAt: Date.now() - 60 * 60 * 1000,
|
||||
vehicleId: 'mock-1',
|
||||
vin: `…${MOCK_VEHICLE.vin.slice(-4)}`,
|
||||
carType: MOCK_VEHICLE.carType,
|
||||
trimBadging: MOCK_VEHICLE.trimBadging,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockState(): TeslaState | null {
|
||||
const s = getMockScenario();
|
||||
if (!s || s === 'asleep') return null;
|
||||
|
||||
const common = {
|
||||
connected: true,
|
||||
asleep: false,
|
||||
carType: MOCK_VEHICLE.carType,
|
||||
trimBadging: MOCK_VEHICLE.trimBadging,
|
||||
vin: `…${MOCK_VEHICLE.vin.slice(-4)}`,
|
||||
vehicleName: MOCK_VEHICLE.vehicleName,
|
||||
softwareVersion: MOCK_VEHICLE.softwareVersion,
|
||||
odometerKm: 24_500,
|
||||
heading: s === 'driving' ? 92 : 0,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (s === 'parked') {
|
||||
return {
|
||||
...common,
|
||||
battery: 65,
|
||||
rangeKm: 320,
|
||||
idealRangeKm: 330,
|
||||
chargingState: 'Disconnected',
|
||||
chargerPowerKw: null,
|
||||
timeToFullCharge: null,
|
||||
lat: BASE_LAT,
|
||||
lng: BASE_LNG,
|
||||
speedKmh: 0,
|
||||
shiftState: 'P' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (s === 'charging') {
|
||||
return {
|
||||
...common,
|
||||
battery: 47,
|
||||
rangeKm: 240,
|
||||
idealRangeKm: 250,
|
||||
chargingState: 'Charging',
|
||||
chargerPowerKw: 132,
|
||||
timeToFullCharge: 0.55, // ~33 min
|
||||
lat: 52.0461, // Baldock services
|
||||
lng: -0.2024,
|
||||
speedKmh: 0,
|
||||
shiftState: 'P' as const,
|
||||
activeRoute: {
|
||||
destination: 'Eurotunnel Folkestone',
|
||||
lat: 51.0901,
|
||||
lng: 1.1340,
|
||||
kmToArrival: 178,
|
||||
minutesToArrival: 132,
|
||||
trafficMinutesDelay: 8,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// driving — animate battery + position with each tick
|
||||
driveTickCount++;
|
||||
driveBattery = Math.max(8, driveBattery - 0.18);
|
||||
return {
|
||||
...common,
|
||||
battery: Math.round(driveBattery),
|
||||
rangeKm: Math.round(driveBattery * 4.9),
|
||||
idealRangeKm: Math.round(driveBattery * 5.1),
|
||||
chargingState: 'Disconnected',
|
||||
chargerPowerKw: null,
|
||||
timeToFullCharge: null,
|
||||
// Crude eastbound drift across the East Anglian flatlands.
|
||||
lat: BASE_LAT - 0.002 * driveTickCount,
|
||||
lng: BASE_LNG + 0.008 * driveTickCount,
|
||||
speedKmh: 112,
|
||||
shiftState: 'D' as const,
|
||||
activeRoute: {
|
||||
destination: 'Cambridge Services M11',
|
||||
lat: 52.1389,
|
||||
lng: 0.1832,
|
||||
kmToArrival: Math.max(0, 78 - driveTickCount * 0.6),
|
||||
minutesToArrival: Math.max(0, Math.round((78 - driveTickCount * 0.6) / 110 * 60)),
|
||||
trafficMinutesDelay: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset the running driving simulation back to start. */
|
||||
export function resetMockDrive(): void {
|
||||
driveBattery = 64;
|
||||
driveTickCount = 0;
|
||||
}
|
||||
@@ -4,12 +4,17 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import { Toaster } from 'sonner';
|
||||
import App from './App';
|
||||
import './styles/globals.css';
|
||||
import { detectInCar, applyInCarClass } from './lib/incar';
|
||||
import { SwNotifier } from './components/SwNotifier';
|
||||
|
||||
applyInCarClass(detectInCar());
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
<SwNotifier />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
+4244
-329
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;
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
+70
-1
@@ -1,9 +1,78 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'Grok Drive',
|
||||
short_name: 'GrokDrive',
|
||||
description: 'AI-assisted Tesla road trip planner',
|
||||
theme_color: '#0a0a0c',
|
||||
background_color: '#0a0a0c',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,ico,woff,woff2}'],
|
||||
globIgnores: ['**/*.map'],
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/\.well-known\//],
|
||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
||||
runtimeCaching: [
|
||||
// Map tiles — cache-first for offline viewing of the planned route.
|
||||
{
|
||||
urlPattern: ({ url }: { url: URL }) =>
|
||||
url.hostname.endsWith('tile.openstreetmap.org')
|
||||
|| url.hostname.endsWith('basemaps.cartocdn.com')
|
||||
|| /tile/i.test(url.hostname),
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'map-tiles',
|
||||
expiration: { maxEntries: 800, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// Geocoding + routing APIs — network-first, with cached fallback.
|
||||
{
|
||||
urlPattern: ({ url }: { url: URL }) =>
|
||||
url.hostname.endsWith('nominatim.openstreetmap.org')
|
||||
|| url.hostname.endsWith('router.project-osrm.org'),
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'geo-api',
|
||||
networkTimeoutSeconds: 4,
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// Our own API — network-first, cached short-term so the last good
|
||||
// response stays visible when LTE drops.
|
||||
{
|
||||
urlPattern: ({ url }: { url: URL }) => url.pathname.startsWith('/api/'),
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'app-api',
|
||||
networkTimeoutSeconds: 3,
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: { enabled: false },
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
|
||||
@@ -24,4 +24,23 @@ export const env = {
|
||||
xaiApiKey: process.env.XAI_API_KEY || '',
|
||||
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
||||
forceXaiApi: process.env.FORCE_XAI_API === 'true',
|
||||
|
||||
// Owner auth — single-user gate for the Tesla integration until
|
||||
// auth.tony.codes is wired in. Set OWNER_SECRET to a long random string.
|
||||
ownerSecret: process.env.OWNER_SECRET || '',
|
||||
|
||||
// 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,14 +3,25 @@ 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 ownerRoutes from './routes/owner.js';
|
||||
import { warnIfMisconfigured as warnOwnerAuth } from './lib/ownerAuth.js';
|
||||
import { createOptionalAuth } from './lib/auth.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// We sit behind one Nginx hop (Dokku's per-app vhost). Trust exactly one
|
||||
// proxy so req.ip reflects the real client and the rate limiters below
|
||||
// can't be bypassed via a spoofed X-Forwarded-For header.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(cors({ origin: env.appUrl, credentials: true }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
@@ -34,9 +45,33 @@ if (auth) {
|
||||
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
||||
}
|
||||
|
||||
// Owner auth + Tesla integration. Tesla routes are owner-gated except the
|
||||
// public .well-known partner-key path. Owner routes handle login/logout.
|
||||
warnOwnerAuth();
|
||||
app.use(ownerRoutes);
|
||||
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' });
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from './logger.js';
|
||||
|
||||
const log = createLogger('owner-auth');
|
||||
|
||||
const COOKIE_NAME = 'owner_session';
|
||||
const COOKIE_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
|
||||
const OWNER_ID = 'owner';
|
||||
|
||||
function expectedCookieValue(): string {
|
||||
if (!env.ownerSecret) return '';
|
||||
return crypto.createHmac('sha256', env.ownerSecret).update('owner').digest('hex');
|
||||
}
|
||||
|
||||
export function setOwnerCookie(res: Response): void {
|
||||
res.cookie(COOKIE_NAME, expectedCookieValue(), {
|
||||
httpOnly: true,
|
||||
secure: env.nodeEnv === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: COOKIE_MAX_AGE_MS,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export function clearOwnerCookie(res: Response): void {
|
||||
res.clearCookie(COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
/** True when the request carries a valid owner session cookie. */
|
||||
export function isOwnerAuthenticated(req: Request): boolean {
|
||||
if (!env.ownerSecret) return false;
|
||||
const got = (req as any).cookies?.[COOKIE_NAME];
|
||||
if (typeof got !== 'string' || got.length === 0) return false;
|
||||
const expected = expectedCookieValue();
|
||||
if (got.length !== expected.length) return false;
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the authenticated owner id or null. Always a single 'owner' id today. */
|
||||
export function ownerIdFromRequest(req: Request): string | null {
|
||||
return isOwnerAuthenticated(req) ? OWNER_ID : null;
|
||||
}
|
||||
|
||||
/** Express middleware: 401 if the request is not owner-authenticated. */
|
||||
export function requireOwner(req: Request, res: Response, next: NextFunction): void {
|
||||
if (isOwnerAuthenticated(req)) return next();
|
||||
res.status(401).json({ error: 'auth_required' });
|
||||
}
|
||||
|
||||
/** Validates an owner secret against env.ownerSecret in constant time. */
|
||||
export function verifyOwnerSecret(input: unknown): boolean {
|
||||
if (!env.ownerSecret || typeof input !== 'string') return false;
|
||||
const a = Buffer.from(input);
|
||||
const b = Buffer.from(env.ownerSecret);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/** Logs a warning if owner auth isn't configured — Tesla routes will return 401. */
|
||||
export function warnIfMisconfigured(): void {
|
||||
if (!env.ownerSecret) {
|
||||
log.warn('OWNER_SECRET is not set — Tesla routes will refuse all requests with 401. Set it to enable owner login.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from './logger.js';
|
||||
import { teslaTokenStore, TeslaTokens } from './teslaTokenStore.js';
|
||||
|
||||
const log = createLogger('tesla-client');
|
||||
|
||||
// ─── Endpoints ──────────────────────────────────────────────────────────────
|
||||
export const TESLA_AUTH_BASE = 'https://auth.tesla.com';
|
||||
export const TESLA_FLEET_BASE = env.tesla.region === 'eu'
|
||||
? 'https://fleet-api.prd.eu.vn.cloud.tesla.com'
|
||||
: 'https://fleet-api.prd.na.vn.cloud.tesla.com';
|
||||
|
||||
export const TESLA_SCOPES = [
|
||||
'openid',
|
||||
'offline_access',
|
||||
'vehicle_device_data',
|
||||
'vehicle_location',
|
||||
'vehicle_cmds',
|
||||
'vehicle_charging_cmds',
|
||||
].join(' ');
|
||||
|
||||
// ─── OAuth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildAuthorizeUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: env.tesla.clientId,
|
||||
redirect_uri: env.tesla.redirectUri,
|
||||
scope: TESLA_SCOPES,
|
||||
state,
|
||||
prompt: 'login',
|
||||
locale: 'en-GB',
|
||||
});
|
||||
return `${TESLA_AUTH_BASE}/oauth2/v3/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
id_token?: string;
|
||||
expires_in: number; // seconds
|
||||
token_type: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
async function tokenRequest(form: URLSearchParams): Promise<TokenResponse> {
|
||||
const res = await fetch(`${TESLA_AUTH_BASE}/oauth2/v3/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
log.error({ status: res.status, body: text.slice(0, 400) }, 'Tesla token request failed');
|
||||
throw new Error(`Tesla OAuth ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(code: string): Promise<TokenResponse> {
|
||||
return tokenRequest(new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: env.tesla.clientId,
|
||||
client_secret: env.tesla.clientSecret,
|
||||
code,
|
||||
redirect_uri: env.tesla.redirectUri,
|
||||
audience: TESLA_FLEET_BASE,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
||||
return tokenRequest(new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: env.tesla.clientId,
|
||||
refresh_token: refreshToken,
|
||||
scope: TESLA_SCOPES,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Token helper ───────────────────────────────────────────────────────────
|
||||
// Returns a valid access token for the user, refreshing if it's within 60s of
|
||||
// expiry. Saves the rotated tokens back to the store.
|
||||
export async function getAccessToken(userId: string): Promise<TeslaTokens | null> {
|
||||
const stored = await teslaTokenStore.get(userId);
|
||||
if (!stored) return null;
|
||||
|
||||
if (stored.expiresAt - Date.now() > 60_000) return stored;
|
||||
|
||||
try {
|
||||
const fresh = await refreshTokens(stored.refreshToken);
|
||||
const updated: TeslaTokens = {
|
||||
...stored,
|
||||
accessToken: fresh.access_token,
|
||||
refreshToken: fresh.refresh_token || stored.refreshToken,
|
||||
expiresAt: Date.now() + fresh.expires_in * 1000,
|
||||
scope: fresh.scope || stored.scope,
|
||||
};
|
||||
await teslaTokenStore.set(userId, updated);
|
||||
return updated;
|
||||
} catch (err) {
|
||||
log.warn({ userId, err: String(err) }, 'Token refresh failed — user must reconnect');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fleet API calls ────────────────────────────────────────────────────────
|
||||
|
||||
async function fleetGet(token: string, path: string): Promise<any> {
|
||||
const res = await fetch(`${TESLA_FLEET_BASE}${path}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Fleet GET ${path} → ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fleetPost(token: string, path: string, body: unknown): Promise<any> {
|
||||
const res = await fetch(`${TESLA_FLEET_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Fleet POST ${path} → ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listVehicles(token: string): Promise<any[]> {
|
||||
const out = await fleetGet(token, '/api/1/vehicles');
|
||||
return Array.isArray(out?.response) ? out.response : [];
|
||||
}
|
||||
|
||||
export async function getVehicleData(token: string, vehicleId: string): Promise<any> {
|
||||
// include all the endpoint groups we care about
|
||||
const endpoints = ['charge_state', 'drive_state', 'vehicle_state', 'vehicle_config'].join(';');
|
||||
const out = await fleetGet(token, `/api/1/vehicles/${vehicleId}/vehicle_data?endpoints=${endpoints}`);
|
||||
return out?.response || null;
|
||||
}
|
||||
|
||||
export async function wakeVehicle(token: string, vehicleId: string): Promise<any> {
|
||||
return fleetPost(token, `/api/1/vehicles/${vehicleId}/wake_up`, {});
|
||||
}
|
||||
|
||||
/** Send a navigation destination to the car's in-built nav. */
|
||||
export async function sendNavigationRequest(
|
||||
token: string,
|
||||
vehicleId: string,
|
||||
args: { lat: number; lng: number; name?: string },
|
||||
): Promise<any> {
|
||||
return fleetPost(token, `/api/1/vehicles/${vehicleId}/command/navigation_gps_request`, {
|
||||
lat: args.lat,
|
||||
lon: args.lng,
|
||||
order: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Partner key registration ───────────────────────────────────────────────
|
||||
// One-shot setup — after partner approval, call this once to register our
|
||||
// public key with Tesla so command signing works. Idempotent: safe to call.
|
||||
export async function registerPartnerAccount(token: string, domain: string): Promise<any> {
|
||||
return fleetPost(token, '/api/1/partner_accounts', { domain });
|
||||
}
|
||||
|
||||
// ─── App-level access token (no user) ───────────────────────────────────────
|
||||
// Some Fleet API setup calls (register partner account, fetch public key) use
|
||||
// a token issued via client_credentials, not a user's auth code.
|
||||
export async function getAppToken(): Promise<string> {
|
||||
const res = await tokenRequest(new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: env.tesla.clientId,
|
||||
client_secret: env.tesla.clientSecret,
|
||||
scope: 'openid offline_access vehicle_device_data vehicle_location vehicle_cmds vehicle_charging_cmds',
|
||||
audience: TESLA_FLEET_BASE,
|
||||
}));
|
||||
return res.access_token;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createLogger } from './logger.js';
|
||||
|
||||
const log = createLogger('tesla-token-store');
|
||||
|
||||
export interface TeslaTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number; // ms epoch
|
||||
scope: string;
|
||||
// Optional cached vehicle metadata so we don't have to refetch on every call.
|
||||
vehicleId?: string; // numeric id_s used in Fleet API URLs
|
||||
vin?: string;
|
||||
carType?: string; // e.g. modely
|
||||
trimBadging?: string; // e.g. lrawd
|
||||
connectedAt: number;
|
||||
}
|
||||
|
||||
interface Store {
|
||||
get(userId: string): Promise<TeslaTokens | null>;
|
||||
set(userId: string, tokens: TeslaTokens): Promise<void>;
|
||||
remove(userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
// File-backed store. Falls back to /app/data when running under Dokku (we
|
||||
// will mount a Dokku storage volume on /app/data), or to ./data in dev.
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = process.env.TESLA_TOKEN_DIR
|
||||
|| (process.env.NODE_ENV === 'production' ? '/app/data' : path.resolve(__dirname, '../../data'));
|
||||
const tokenFile = path.join(dataDir, 'tesla-tokens.json');
|
||||
|
||||
let cache: Record<string, TeslaTokens> = {};
|
||||
let loaded = false;
|
||||
let writeLock: Promise<unknown> = Promise.resolve();
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (loaded) return;
|
||||
try {
|
||||
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(dataDir, 0o700).catch(() => {}); // tighten if it already existed
|
||||
const raw = await fs.readFile(tokenFile, 'utf8');
|
||||
cache = JSON.parse(raw);
|
||||
log.info({ file: tokenFile, users: Object.keys(cache).length }, 'Loaded Tesla tokens');
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') log.warn({ err: String(err) }, 'Failed to load Tesla tokens — starting empty');
|
||||
cache = {};
|
||||
}
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
// Serialise writes so concurrent set/remove calls don't race. Write to a
|
||||
// tmpfile then rename for atomicity; owner-only perms via mode + chmod.
|
||||
writeLock = writeLock
|
||||
.then(async () => {
|
||||
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(dataDir, 0o700).catch(() => {});
|
||||
const tmp = `${tokenFile}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
|
||||
await fs.chmod(tmp, 0o600).catch(() => {});
|
||||
await fs.rename(tmp, tokenFile);
|
||||
await fs.chmod(tokenFile, 0o600).catch(() => {});
|
||||
})
|
||||
.catch(err => log.error({ err: String(err) }, 'Failed to persist Tesla tokens'));
|
||||
await writeLock;
|
||||
}
|
||||
|
||||
export const teslaTokenStore: Store = {
|
||||
async get(userId) {
|
||||
await load();
|
||||
return cache[userId] || null;
|
||||
},
|
||||
async set(userId, tokens) {
|
||||
await load();
|
||||
cache[userId] = tokens;
|
||||
await persist();
|
||||
},
|
||||
async remove(userId) {
|
||||
await load();
|
||||
delete cache[userId];
|
||||
await persist();
|
||||
},
|
||||
};
|
||||
+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,159 @@
|
||||
import { Router } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import {
|
||||
clearOwnerCookie,
|
||||
isOwnerAuthenticated,
|
||||
setOwnerCookie,
|
||||
verifyOwnerSecret,
|
||||
} from '../lib/ownerAuth.js';
|
||||
|
||||
const log = createLogger('owner');
|
||||
const router = Router();
|
||||
|
||||
// ─── Device pair-code store (in-memory) ─────────────────────────────────────
|
||||
// 6-digit short codes minted by an already-logged-in owner session so a
|
||||
// touchscreen device (the Tesla in-car browser) can log in without retyping
|
||||
// the long OWNER_SECRET.
|
||||
//
|
||||
// Defence in depth — 6 digits is only ~20 bits of entropy on its own:
|
||||
// - single-use (delete on success)
|
||||
// - 5-minute TTL
|
||||
// - per-code failure cap: a code self-destructs after 5 wrong guesses
|
||||
// - per-IP rate limit (req.ip via 'trust proxy' = 1, not spoofable)
|
||||
// - global rate limit across all IPs (defeats parallel-IP brute force)
|
||||
const PAIR_CODE_TTL_MS = 5 * 60 * 1000;
|
||||
const MAX_FAILED_PER_CODE = 3; // tighter — was 5
|
||||
const MAX_REDEEM_PER_IP_PER_MIN = 8;
|
||||
const MAX_REDEEM_GLOBAL_PER_MIN = 30;
|
||||
const LOCKOUT_TRIP_FAILURES_5MIN = 100; // after this many failures…
|
||||
const LOCKOUT_DURATION_MS = 10 * 60 * 1000; // …block the endpoint for 10 min
|
||||
|
||||
interface PairCodeEntry { expiresAt: number; failures: number }
|
||||
const pairCodes = new Map<string, PairCodeEntry>();
|
||||
const redeemAttemptsByIp = new Map<string, number[]>();
|
||||
let redeemAttemptsGlobal: number[] = [];
|
||||
let failuresLast5Min: number[] = [];
|
||||
let lockedUntil = 0;
|
||||
|
||||
function cleanPairCodes(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of pairCodes) if (v.expiresAt < now) pairCodes.delete(k);
|
||||
}
|
||||
|
||||
function recordAttempt(ip: string): { ipLimited: boolean; globalLimited: boolean } {
|
||||
const now = Date.now();
|
||||
const ipArr = (redeemAttemptsByIp.get(ip) || []).filter(t => now - t < 60_000);
|
||||
ipArr.push(now);
|
||||
redeemAttemptsByIp.set(ip, ipArr);
|
||||
redeemAttemptsGlobal = redeemAttemptsGlobal.filter(t => now - t < 60_000);
|
||||
redeemAttemptsGlobal.push(now);
|
||||
return {
|
||||
ipLimited: ipArr.length > MAX_REDEEM_PER_IP_PER_MIN,
|
||||
globalLimited: redeemAttemptsGlobal.length > MAX_REDEEM_GLOBAL_PER_MIN,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/api/auth/owner/status', (req, res) => {
|
||||
res.json({
|
||||
authenticated: isOwnerAuthenticated(req),
|
||||
required: !!env.ownerSecret,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/api/auth/owner', (req, res) => {
|
||||
if (!env.ownerSecret) {
|
||||
res.status(503).json({ ok: false, reason: 'owner_auth_not_configured' });
|
||||
return;
|
||||
}
|
||||
const { secret } = (req.body || {}) as { secret?: unknown };
|
||||
if (!verifyOwnerSecret(secret)) {
|
||||
res.status(401).json({ ok: false, reason: 'invalid_secret' });
|
||||
return;
|
||||
}
|
||||
setOwnerCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/api/auth/owner/logout', (_req, res) => {
|
||||
clearOwnerCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Pair code: mint one (requires owner session) ──────────────────────────
|
||||
router.post('/api/auth/owner/pair-code', (req, res) => {
|
||||
if (!isOwnerAuthenticated(req)) {
|
||||
res.status(401).json({ ok: false, reason: 'auth_required' });
|
||||
return;
|
||||
}
|
||||
cleanPairCodes();
|
||||
// Single-tenant app: only one active pair code at a time. Minting a new
|
||||
// one burns any prior code (its window is what owner-attacker brute-force
|
||||
// would target).
|
||||
pairCodes.clear();
|
||||
const code = String(crypto.randomInt(100_000, 1_000_000));
|
||||
pairCodes.set(code, { expiresAt: Date.now() + PAIR_CODE_TTL_MS, failures: 0 });
|
||||
res.json({ ok: true, code, expiresInS: Math.floor(PAIR_CODE_TTL_MS / 1000) });
|
||||
});
|
||||
|
||||
// Mark a failed redeem against every currently-active code. With 6-digit
|
||||
// entropy, an attacker brute-forcing the keyspace doesn't know which code
|
||||
// the owner minted — but every wrong guess they make burns life from the
|
||||
// real code. Three misses during a code's TTL is enough to invalidate it
|
||||
// and force the owner to re-mint, which defeats parallel-IP brute force.
|
||||
// Also record the failure on a global 5-minute window for endpoint lockout.
|
||||
function markFailureAgainstActiveCodes(): void {
|
||||
for (const [code, entry] of pairCodes) {
|
||||
entry.failures += 1;
|
||||
if (entry.failures > MAX_FAILED_PER_CODE) {
|
||||
pairCodes.delete(code);
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
failuresLast5Min = failuresLast5Min.filter(t => now - t < 5 * 60_000);
|
||||
failuresLast5Min.push(now);
|
||||
if (failuresLast5Min.length > LOCKOUT_TRIP_FAILURES_5MIN) {
|
||||
lockedUntil = now + LOCKOUT_DURATION_MS;
|
||||
failuresLast5Min = [];
|
||||
log.error({ until: new Date(lockedUntil).toISOString() }, 'pair-redeem endpoint locked due to brute force');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pair code: redeem on a new device ──────────────────────────────────────
|
||||
router.post('/api/auth/owner/pair-redeem', (req, res) => {
|
||||
// Hard endpoint lockout after a brute-force storm.
|
||||
if (Date.now() < lockedUntil) {
|
||||
res.status(429).json({ ok: false, reason: 'endpoint_locked' });
|
||||
return;
|
||||
}
|
||||
// req.ip is trustworthy because we set 'trust proxy' = 1 in server/index.ts.
|
||||
// Do NOT fall back to the X-Forwarded-For header — it's client-controlled.
|
||||
const ip = req.ip || 'unknown';
|
||||
const { ipLimited, globalLimited } = recordAttempt(ip);
|
||||
if (ipLimited || globalLimited) {
|
||||
log.warn({ ip, ipLimited, globalLimited }, 'pair-redeem rate limited');
|
||||
res.status(429).json({ ok: false, reason: 'rate_limited' });
|
||||
return;
|
||||
}
|
||||
const { code } = (req.body || {}) as { code?: unknown };
|
||||
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
||||
markFailureAgainstActiveCodes();
|
||||
res.status(400).json({ ok: false, reason: 'bad_code' });
|
||||
return;
|
||||
}
|
||||
cleanPairCodes();
|
||||
const entry = pairCodes.get(code);
|
||||
if (!entry) {
|
||||
markFailureAgainstActiveCodes();
|
||||
res.status(401).json({ ok: false, reason: 'invalid_or_expired' });
|
||||
return;
|
||||
}
|
||||
// Success: consume the code, set the cookie.
|
||||
pairCodes.delete(code);
|
||||
setOwnerCookie(res);
|
||||
log.info({ ip }, 'Device paired via owner pair code');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { teslaTokenStore } from '../lib/teslaTokenStore.js';
|
||||
import { ownerIdFromRequest, requireOwner } from '../lib/ownerAuth.js';
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCodeForTokens,
|
||||
getAccessToken,
|
||||
getVehicleData,
|
||||
listVehicles,
|
||||
sendNavigationRequest,
|
||||
wakeVehicle,
|
||||
registerPartnerAccount,
|
||||
getAppToken,
|
||||
} from '../lib/teslaClient.js';
|
||||
|
||||
const log = createLogger('tesla');
|
||||
const router = Router();
|
||||
|
||||
const TESLA_STATE_COOKIE = 'tesla_oauth_state';
|
||||
const STATE_TTL_S = 10 * 60;
|
||||
|
||||
// ─── Domain verification (PUBLIC — Tesla fetches anonymously) ───────────────
|
||||
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);
|
||||
});
|
||||
|
||||
// ─── Connect status (PUBLIC — used by client to decide UI state) ────────────
|
||||
// Reveals only whether the integration is configured / connected; never
|
||||
// emits any credentials or vehicle data.
|
||||
router.get('/api/tesla/status', async (req, res) => {
|
||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||
res.json({ available: false, reason: 'pending_partner_approval' });
|
||||
return;
|
||||
}
|
||||
const ownerId = ownerIdFromRequest(req);
|
||||
const tokens = ownerId ? await teslaTokenStore.get(ownerId) : null;
|
||||
res.json({
|
||||
available: true,
|
||||
connected: !!tokens,
|
||||
ownerAuthenticated: !!ownerId,
|
||||
connectedAt: tokens?.connectedAt ?? null,
|
||||
vehicleId: tokens?.vehicleId ?? null,
|
||||
vin: tokens?.vin ? `…${tokens.vin.slice(-4)}` : null, // masked
|
||||
carType: tokens?.carType ?? null,
|
||||
trimBadging: tokens?.trimBadging ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Everything below requires owner auth ──────────────────────────────────
|
||||
|
||||
// ─── Start the OAuth dance ──────────────────────────────────────────────────
|
||||
router.get('/api/auth/tesla/start', requireOwner, (req, res) => {
|
||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||
res.status(503).json({ error: 'tesla_not_configured' });
|
||||
return;
|
||||
}
|
||||
// Random state — bound to the browser via an httpOnly cookie so a CSRF
|
||||
// attacker can't pre-seed a state that lands tokens in our account.
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
res.cookie(TESLA_STATE_COOKIE, state, {
|
||||
httpOnly: true,
|
||||
secure: env.nodeEnv === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: STATE_TTL_S * 1000,
|
||||
path: '/',
|
||||
});
|
||||
const url = buildAuthorizeUrl(state);
|
||||
res.json({ authorizeUrl: url });
|
||||
});
|
||||
|
||||
// ─── OAuth callback ─────────────────────────────────────────────────────────
|
||||
// Tesla redirects the browser here after the user authorises. The owner cookie
|
||||
// from when /start was called must still be present (SameSite=Lax allows it
|
||||
// through on the top-level navigation back from Tesla), and the state in the
|
||||
// cookie must equal the state Tesla bounced back.
|
||||
router.get('/api/auth/tesla/callback', async (req, res) => {
|
||||
const { code, state, error } = req.query as Record<string, string | undefined>;
|
||||
const cookieState = (req as any).cookies?.[TESLA_STATE_COOKIE];
|
||||
|
||||
// Always clear the state cookie — single-use.
|
||||
res.clearCookie(TESLA_STATE_COOKIE, { path: '/' });
|
||||
|
||||
if (error) {
|
||||
log.warn({ error }, 'Tesla OAuth error');
|
||||
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
|
||||
return;
|
||||
}
|
||||
if (!code || !state) {
|
||||
res.status(400).type('text/plain').send('Missing code or state');
|
||||
return;
|
||||
}
|
||||
if (!cookieState || typeof cookieState !== 'string' || cookieState.length !== state.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(state))) {
|
||||
log.warn({ haveCookie: !!cookieState }, 'OAuth state cookie missing or mismatched — rejecting');
|
||||
res.status(400).type('text/plain').send('OAuth state mismatch — restart from the planner');
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = ownerIdFromRequest(req);
|
||||
if (!ownerId) {
|
||||
// Owner cookie expired or the user opened the callback in a different
|
||||
// browser. Refuse rather than fall back to a global identity.
|
||||
res.status(401).type('text/plain').send('Owner session expired — log in and reconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens(code);
|
||||
let vehicleId: string | undefined;
|
||||
let vin: string | undefined;
|
||||
let carType: string | undefined;
|
||||
let trimBadging: string | undefined;
|
||||
try {
|
||||
const vehicles = await listVehicles(tokens.access_token);
|
||||
const first = vehicles[0];
|
||||
if (first) {
|
||||
vehicleId = String(first.id_s ?? first.id ?? '');
|
||||
vin = first.vin;
|
||||
}
|
||||
if (vehicleId) {
|
||||
try {
|
||||
const data = await getVehicleData(tokens.access_token, vehicleId);
|
||||
carType = data?.vehicle_config?.car_type;
|
||||
trimBadging = data?.vehicle_config?.trim_badging;
|
||||
} catch (e) {
|
||||
log.info({ err: String(e) }, 'Vehicle asleep at connect — config will populate later');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn({ err: String(e) }, 'listVehicles failed at connect');
|
||||
}
|
||||
|
||||
await teslaTokenStore.set(ownerId, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||
scope: tokens.scope || '',
|
||||
vehicleId,
|
||||
vin,
|
||||
carType,
|
||||
trimBadging,
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
|
||||
log.info({ ownerId, vehicleId, vin: vin?.slice(-4) }, 'Tesla connected');
|
||||
res.redirect('/?tesla_connected=1');
|
||||
} catch (err) {
|
||||
log.error({ err: String(err) }, 'Tesla OAuth callback failed');
|
||||
res.redirect(`/?tesla_error=${encodeURIComponent('token_exchange_failed')}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Disconnect ─────────────────────────────────────────────────────────────
|
||||
router.post('/api/tesla/disconnect', requireOwner, async (req, res) => {
|
||||
const ownerId = ownerIdFromRequest(req)!;
|
||||
await teslaTokenStore.remove(ownerId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Vehicle state (battery, range, location) ───────────────────────────────
|
||||
router.get('/api/tesla/state', requireOwner, async (req, res) => {
|
||||
if (!env.tesla.clientId) {
|
||||
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
|
||||
return;
|
||||
}
|
||||
const ownerId = ownerIdFromRequest(req)!;
|
||||
const tokens = await getAccessToken(ownerId);
|
||||
if (!tokens) {
|
||||
res.status(401).json({ connected: false, reason: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
if (!tokens.vehicleId) {
|
||||
res.status(404).json({ connected: true, reason: 'no_vehicles' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getVehicleData(tokens.accessToken, tokens.vehicleId);
|
||||
const cs = data?.charge_state || {};
|
||||
const ds = data?.drive_state || {};
|
||||
const vs = data?.vehicle_state || {};
|
||||
const vc = data?.vehicle_config || {};
|
||||
res.json({
|
||||
connected: true,
|
||||
asleep: false,
|
||||
battery: typeof cs.battery_level === 'number' ? cs.battery_level : null,
|
||||
rangeKm: typeof cs.battery_range === 'number' ? Math.round(cs.battery_range * 1.60934) : null,
|
||||
idealRangeKm: typeof cs.ideal_battery_range === 'number' ? Math.round(cs.ideal_battery_range * 1.60934) : null,
|
||||
chargingState: cs.charging_state ?? null,
|
||||
chargerPowerKw: typeof cs.charger_power === 'number' ? cs.charger_power : null,
|
||||
timeToFullCharge: typeof cs.time_to_full_charge === 'number' ? cs.time_to_full_charge : null,
|
||||
lat: typeof ds.latitude === 'number' ? ds.latitude : null,
|
||||
lng: typeof ds.longitude === 'number' ? ds.longitude : null,
|
||||
heading: typeof ds.heading === 'number' ? ds.heading : null,
|
||||
speedKmh: typeof ds.speed === 'number' ? Math.round(ds.speed * 1.60934) : null,
|
||||
shiftState: ds.shift_state ?? null,
|
||||
odometerKm: typeof vs.odometer === 'number' ? Math.round(vs.odometer * 1.60934) : null,
|
||||
carType: vc.car_type ?? tokens.carType ?? null,
|
||||
trimBadging: vc.trim_badging ?? tokens.trimBadging ?? null,
|
||||
vin: tokens.vin ? `…${tokens.vin.slice(-4)}` : null,
|
||||
vehicleName: vs.vehicle_name ?? null,
|
||||
softwareVersion: vs.car_version ?? null,
|
||||
// Active in-car nav destination (set in Tesla nav by the driver, or by
|
||||
// our own send-to-nav). Useful as a default trip destination.
|
||||
activeRoute: (typeof ds.active_route_destination === 'string' && ds.active_route_destination)
|
||||
? {
|
||||
destination: ds.active_route_destination,
|
||||
lat: typeof ds.active_route_latitude === 'number' ? ds.active_route_latitude : null,
|
||||
lng: typeof ds.active_route_longitude === 'number' ? ds.active_route_longitude : null,
|
||||
kmToArrival: typeof ds.active_route_miles_to_arrival === 'number'
|
||||
? Math.round(ds.active_route_miles_to_arrival * 1.60934) : null,
|
||||
minutesToArrival: typeof ds.active_route_minutes_to_arrival === 'number'
|
||||
? Math.round(ds.active_route_minutes_to_arrival) : null,
|
||||
trafficMinutesDelay: typeof ds.active_route_traffic_minutes_delay === 'number'
|
||||
? Math.round(ds.active_route_traffic_minutes_delay) : 0,
|
||||
}
|
||||
: null,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (/408/.test(msg) || /asleep/i.test(msg)) {
|
||||
res.status(202).json({ connected: true, asleep: true, vehicleId: tokens.vehicleId });
|
||||
return;
|
||||
}
|
||||
log.error({ err: msg }, 'Failed to fetch vehicle state');
|
||||
res.status(502).json({ connected: true, error: 'fleet_api_error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tesla/wake', requireOwner, async (req, res) => {
|
||||
const ownerId = ownerIdFromRequest(req)!;
|
||||
const tokens = await getAccessToken(ownerId);
|
||||
if (!tokens || !tokens.vehicleId) {
|
||||
res.status(401).json({ ok: false, reason: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await wakeVehicle(tokens.accessToken, tokens.vehicleId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error({ err: String(err) }, 'Wake failed');
|
||||
res.status(502).json({ ok: false, reason: 'wake_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tesla/send-to-nav', requireOwner, async (req: Request, res: Response) => {
|
||||
const ownerId = ownerIdFromRequest(req)!;
|
||||
const tokens = await getAccessToken(ownerId);
|
||||
if (!tokens || !tokens.vehicleId) {
|
||||
res.status(401).json({ ok: false, reason: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
const { lat, lng, name } = req.body || {};
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number'
|
||||
|| lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
res.status(400).json({ ok: false, reason: 'bad_coords' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendNavigationRequest(tokens.accessToken, tokens.vehicleId, { lat, lng, name });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error({ err: String(err) }, 'send-to-nav failed');
|
||||
res.status(502).json({ ok: false, reason: 'fleet_api_error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Partner-account registration ─────────────────────────────────────────
|
||||
// One-shot. Owner-gated. Does NOT echo Tesla's response body to the client.
|
||||
router.post('/api/tesla/register-partner', requireOwner, async (_req, res) => {
|
||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||
res.status(503).json({ ok: false, reason: 'tesla_not_configured' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appToken = await getAppToken();
|
||||
const domain = new URL(env.tesla.redirectUri).hostname;
|
||||
await registerPartnerAccount(appToken, domain);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error({ err: String(err) }, 'partner registration failed');
|
||||
res.status(502).json({ ok: false, reason: 'partner_register_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
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