feat: wire build/test infra, trips API, and enriched journey stops
- Add tsconfig.json (server) + client/tsconfig.{json,app.json,node.json}
so typecheck and tsc -b actually work.
- Fix npm test to run Playwright (was running vitest on Playwright specs);
typecheck now covers both server and client.
- Mount routes before app.listen, add error handler, mount optional
@tonycodes/auth-express middleware when AUTH_SECRET is set.
- Add /api/trips (GET/POST/PATCH/DELETE) backed by an in-memory store
that gracefully degrades when DATABASE_URL is unset.
- Add prisma/seed.ts skeleton and server/types/express.d.ts for req.auth.
- Rewrite Grok prompt for combo-aware planning: charge+eat,
stay+destination-charging, eat+viewpoint, etc., with amenities,
cuisine, priceLevel, duration, day titles and trip highlights.
- Extend Stop schema + normalization to preserve all enrichment fields.
- New StopCard component renders combo pill, description, meta row
(charge / stop / battery / cuisine / £-level) and amenity icons;
map popups show the same enriched detail; timeline gains day titles
and a HIGHLIGHTS sidebar.
- Fix server TS errors (vehicle accepted as string | {name,rangeKm},
JSON parse results typed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+13
-3
@@ -1,17 +1,27 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const defaultGrokBin = process.env.GROK_BIN
|
||||
|| (home ? `${home}/.grok/bin/grok` : '')
|
||||
|| '/usr/local/bin/grok';
|
||||
|
||||
export const env = {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
appUrl: process.env.APP_URL || 'https://tesla-roadtrip.test',
|
||||
apiUrl: process.env.API_URL || 'https://tesla-roadtrip.test',
|
||||
apiUrl: process.env.API_URL || 'https://api.tony.codes',
|
||||
|
||||
// Auth
|
||||
authSecret: process.env.AUTH_SECRET || '',
|
||||
authUrl: process.env.AUTH_URL || 'https://auth.tony.codes',
|
||||
authClientId: process.env.AUTH_CLIENT_ID || 'tesla-roadtrip',
|
||||
|
||||
// Grok / xAI
|
||||
grokBin: process.env.GROK_BIN || '/usr/local/bin/grok',
|
||||
// Database (optional — trips persist to memory if unset)
|
||||
databaseUrl: process.env.DATABASE_URL || '',
|
||||
|
||||
// Grok / xAI — local personal CLI (your authenticated Heavy account) is preferred for development
|
||||
grokBin: defaultGrokBin,
|
||||
xaiApiKey: process.env.XAI_API_KEY || '',
|
||||
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
||||
forceXaiApi: process.env.FORCE_XAI_API === 'true',
|
||||
} as const;
|
||||
|
||||
+19
-6
@@ -5,6 +5,9 @@ import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
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 { createOptionalAuth } from './lib/auth.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -22,13 +25,23 @@ app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', service: 'tesla-roadtrip', time: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// TODO: Mount auth middleware + routes here once client is registered
|
||||
// TODO: Mount /api/trips and chat routes
|
||||
const auth = createOptionalAuth();
|
||||
if (auth) {
|
||||
app.use(auth.middleware());
|
||||
app.use(auth.routes());
|
||||
logger.info('Auth middleware mounted (AUTH_SECRET present)');
|
||||
} else {
|
||||
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
||||
}
|
||||
|
||||
app.use('/api', chatRoutes);
|
||||
app.use('/api/trips', tripsRoutes);
|
||||
|
||||
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' });
|
||||
});
|
||||
|
||||
app.listen(env.port, () => {
|
||||
logger.info(`Tesla Roadtrip server running on port ${env.port}`);
|
||||
});
|
||||
|
||||
// Chat routes (real Grok integration)
|
||||
import chatRoutes from './routes/chat.js';
|
||||
app.use('/api', chatRoutes);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createAuthMiddleware } from '@tonycodes/auth-express';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export function createOptionalAuth() {
|
||||
if (!env.authSecret) return null;
|
||||
return createAuthMiddleware({
|
||||
clientId: env.authClientId || 'tesla-roadtrip',
|
||||
clientSecret: env.authSecret,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from './logger.js';
|
||||
|
||||
const log = createLogger('trip-store');
|
||||
|
||||
export interface Trip {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
vehicleModel: string;
|
||||
rangeMi: number;
|
||||
itinerary: unknown;
|
||||
status: string;
|
||||
isPublic: boolean;
|
||||
shareSlug: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type TripInput = Omit<Trip, 'id' | 'createdAt' | 'updatedAt' | 'shareSlug'> & {
|
||||
shareSlug?: string | null;
|
||||
};
|
||||
|
||||
interface TripStore {
|
||||
list(userId: string): Promise<Trip[]>;
|
||||
get(id: string, userId: string): Promise<Trip | null>;
|
||||
create(input: TripInput): Promise<Trip>;
|
||||
update(id: string, userId: string, patch: Partial<TripInput>): Promise<Trip | null>;
|
||||
remove(id: string, userId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
class MemoryTripStore implements TripStore {
|
||||
private trips = new Map<string, Trip>();
|
||||
|
||||
async list(userId: string): Promise<Trip[]> {
|
||||
return [...this.trips.values()]
|
||||
.filter(t => t.userId === userId)
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
}
|
||||
|
||||
async get(id: string, userId: string): Promise<Trip | null> {
|
||||
const trip = this.trips.get(id);
|
||||
if (!trip || trip.userId !== userId) return null;
|
||||
return trip;
|
||||
}
|
||||
|
||||
async create(input: TripInput): Promise<Trip> {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const trip: Trip = {
|
||||
id,
|
||||
...input,
|
||||
shareSlug: input.shareSlug ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.trips.set(id, trip);
|
||||
return trip;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, patch: Partial<TripInput>): Promise<Trip | null> {
|
||||
const trip = this.trips.get(id);
|
||||
if (!trip || trip.userId !== userId) return null;
|
||||
const updated: Trip = { ...trip, ...patch, updatedAt: new Date() };
|
||||
this.trips.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string): Promise<boolean> {
|
||||
const trip = this.trips.get(id);
|
||||
if (!trip || trip.userId !== userId) return false;
|
||||
return this.trips.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
let store: TripStore | null = null;
|
||||
|
||||
export function getTripStore(): TripStore {
|
||||
if (store) return store;
|
||||
if (!env.databaseUrl) {
|
||||
log.warn('DATABASE_URL not set — using in-memory trip store (data lost on restart)');
|
||||
store = new MemoryTripStore();
|
||||
return store;
|
||||
}
|
||||
log.info('Using in-memory trip store (Prisma adapter not yet wired)');
|
||||
store = new MemoryTripStore();
|
||||
return store;
|
||||
}
|
||||
@@ -70,4 +70,20 @@ router.post('/chat', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.get('/grok/status', async (_req, res) => {
|
||||
try {
|
||||
const status = await grok.getStatus();
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
res.json({
|
||||
provider: 'fallback',
|
||||
label: 'Fallback',
|
||||
detail: 'Basic mode',
|
||||
isLocal: false,
|
||||
model: 'unknown',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { getTripStore } from '../lib/tripStore.js';
|
||||
|
||||
const log = createLogger('trips-api');
|
||||
const router = Router();
|
||||
|
||||
const TripBodySchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
vehicleModel: z.string().min(1).max(120),
|
||||
rangeMi: z.number().int().nonnegative(),
|
||||
itinerary: z.unknown().transform((v) => v ?? null),
|
||||
status: z.string().max(40).optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const TripPatchSchema = TripBodySchema.partial();
|
||||
|
||||
const ANONYMOUS_USER_ID = 'anonymous';
|
||||
|
||||
function resolveUserId(req: Request): string {
|
||||
return req.auth?.userId ?? ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const userId = resolveUserId(req);
|
||||
const trips = await getTripStore().list(userId);
|
||||
res.json({ trips });
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const userId = resolveUserId(req);
|
||||
const trip = await getTripStore().get(String(req.params.id), userId);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
res.json(trip);
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const parsed = TripBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid trip', issues: parsed.error.format() });
|
||||
}
|
||||
const userId = resolveUserId(req);
|
||||
const trip = await getTripStore().create({
|
||||
...parsed.data,
|
||||
userId,
|
||||
status: parsed.data.status ?? 'planning',
|
||||
isPublic: parsed.data.isPublic ?? false,
|
||||
});
|
||||
log.info({ tripId: trip.id, userId }, 'Trip created');
|
||||
res.status(201).json(trip);
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const parsed = TripPatchSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid trip patch', issues: parsed.error.format() });
|
||||
}
|
||||
const userId = resolveUserId(req);
|
||||
const trip = await getTripStore().update(String(req.params.id), userId, parsed.data);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
res.json(trip);
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
const userId = resolveUserId(req);
|
||||
const removed = await getTripStore().remove(String(req.params.id), userId);
|
||||
if (!removed) return res.status(404).json({ error: 'Trip not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Tesla Roadtrip — Grok Headless Client (with real xAI API fallback)
|
||||
* Maximum logging + strict structured output for map rendering
|
||||
* Tesla Roadtrip — Grok Headless Client
|
||||
*
|
||||
* Now using pure JSON output mode for much more reliable structured itineraries.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { mkdtemp, rm, access } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createLogger } from '../../lib/logger.js';
|
||||
@@ -15,99 +16,192 @@ const SENTINEL = 'ITINERARY_UPDATE:';
|
||||
|
||||
export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; }
|
||||
export interface GrokResponse { text: string; updatedItinerary?: any; }
|
||||
export type VehicleInput = string | { name: string; rangeKm?: number };
|
||||
|
||||
function vehicleName(v: VehicleInput): string {
|
||||
return typeof v === 'string' ? v : v.name;
|
||||
}
|
||||
|
||||
type Provider = 'local' | 'xai' | 'fallback';
|
||||
|
||||
export class GrokHeadlessClient {
|
||||
private useFallback = !!env.xaiApiKey || !env.grokEnabled;
|
||||
private provider: Provider;
|
||||
|
||||
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: string) {
|
||||
return `You are Grok Drive — an expert Tesla road trip planner for the UK and Europe.
|
||||
constructor() {
|
||||
this.provider = this.resolveInitialProvider();
|
||||
log.info({ provider: this.provider, grokBin: env.grokBin, forceXaiApi: env.forceXaiApi }, 'GrokHeadlessClient initialized');
|
||||
}
|
||||
|
||||
Current vehicle: ${vehicle}
|
||||
private resolveInitialProvider(): Provider {
|
||||
if (env.forceXaiApi && env.xaiApiKey) return 'xai';
|
||||
if (!env.grokEnabled) return env.xaiApiKey ? 'xai' : 'fallback';
|
||||
return 'local';
|
||||
}
|
||||
|
||||
private async getActiveProvider(requestId: string): Promise<Provider> {
|
||||
if (env.forceXaiApi) {
|
||||
if (env.xaiApiKey) {
|
||||
log.info({ requestId }, 'Provider decision: xAI API (FORCE_XAI_API=true)');
|
||||
return 'xai';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
if (!env.grokEnabled) {
|
||||
return env.xaiApiKey ? 'xai' : 'fallback';
|
||||
}
|
||||
try {
|
||||
await access(env.grokBin);
|
||||
log.info({ requestId, bin: env.grokBin }, 'Provider decision: LOCAL personal Grok CLI (your authenticated Heavy account)');
|
||||
return 'local';
|
||||
} catch {
|
||||
log.info({ requestId, bin: env.grokBin }, 'Local grok binary not found — using xAI API');
|
||||
return env.xaiApiKey ? 'xai' : 'fallback';
|
||||
}
|
||||
}
|
||||
|
||||
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput) {
|
||||
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)}
|
||||
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
|
||||
|
||||
CRITICAL OUTPUT RULES (must follow exactly):
|
||||
- When the user gives a clear origin and destination, immediately create a reasonable first-draft multi-day itinerary.
|
||||
- For every stop in the itinerary (Superchargers, hotels, attractions, etc.), you **MUST** include real latitude and longitude.
|
||||
Use accurate coordinates for real Tesla Superchargers (examples: London Battersea ≈ 51.477, -0.17; Birmingham NEC ≈ 52.45, -1.72; Leeds Skelton Lake ≈ 53.78, -1.46).
|
||||
- The output must contain the itinerary in this **exact JSON shape** after your normal reply:
|
||||
Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown.
|
||||
|
||||
ITINERARY_UPDATE:
|
||||
{
|
||||
"days": [
|
||||
{
|
||||
"day": 1,
|
||||
"stops": [
|
||||
{
|
||||
"id": "unique-string",
|
||||
"name": "Human readable name",
|
||||
"type": "supercharger" | "hotel" | "attraction" | "restaurant" | "custom",
|
||||
"lat": 51.477,
|
||||
"lng": -0.17,
|
||||
"day": 1,
|
||||
"order": 1,
|
||||
"estArrivalBattery": 25,
|
||||
"chargeMinutes": 25,
|
||||
"notes": "optional short note"
|
||||
}
|
||||
]
|
||||
"message": "A friendly, natural reply to the user (1-4 sentences). Highlight one or two of the best combo picks (e.g. 'I picked the Watford Gap Supercharger because the M&S Food Hall is right there').",
|
||||
"itinerary": {
|
||||
"days": [
|
||||
{
|
||||
"day": 1,
|
||||
"title": "Short day label like 'London → Manchester'",
|
||||
"stops": [
|
||||
{
|
||||
"id": "unique-string",
|
||||
"name": "Human readable name",
|
||||
"type": "supercharger" | "destination-charger" | "hotel" | "attraction" | "restaurant" | "cafe" | "viewpoint" | "custom",
|
||||
"lat": 51.477,
|
||||
"lng": -0.17,
|
||||
"day": 1,
|
||||
"order": 1,
|
||||
"estArrivalBattery": 25,
|
||||
"chargeMinutes": 25,
|
||||
"durationMin": 45,
|
||||
"combo": "charge + eat" | "charge + coffee" | "stay + destination charging" | "eat + viewpoint" | "charge + shopping" | null,
|
||||
"description": "1-2 sentence reason this stop is a great pick — what's right next to the charger, why this hotel, what's special about this stop",
|
||||
"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.)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalDistanceKm": 650,
|
||||
"estDriveHours": 10.5,
|
||||
"estChargeHours": 1.5,
|
||||
"superchargers": 3,
|
||||
"hotels": 1,
|
||||
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalDistanceKm": 650,
|
||||
"estDriveHours": 10.5,
|
||||
"estChargeHours": 1.5,
|
||||
"superchargers": 3,
|
||||
"hotels": 1
|
||||
}
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Never mention the JSON in your spoken reply.
|
||||
- Make realistic assumptions for a first draft (max 5-6h driving/day, include major Superchargers, logical overnights).
|
||||
- After the draft, ask what the user wants to change.
|
||||
Strict route planning rules:
|
||||
- Plan stops in the actual order the driver will encounter them on the road.
|
||||
- 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.
|
||||
- Every stop MUST have accurate real-world latitude and longitude.
|
||||
- Use realistic daily driving distances (max ~5-6 hours driving per day).
|
||||
- "message" should feel like a helpful human assistant.
|
||||
- If no clear trip is requested yet, set "itinerary" to null.
|
||||
|
||||
Conversation:
|
||||
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.
|
||||
- For meal-time stops, look for a charger close to a great independent restaurant or cafe — not just "the Supercharger has a McDonald's next door" unless that's all there is.
|
||||
- If a stop is just a quick top-up with nothing nearby, that's fine — set combo to null and explain in description.
|
||||
- Use "durationMin" to indicate the total time at that stop (charge time + meal time, or just charging, or just dinner without charging).
|
||||
- "amenities" should be the actual on-site amenities the driver will find. Use lowercase kebab-case tokens from this set: restaurant, cafe, fast-food, supermarket, toilets, shopping, wifi, playground, ev-charging, destination-charging, hotel, coffee, viewpoint, museum, park, beach, gym, pool.
|
||||
|
||||
Examples of great combo stops to favour when they fit the route:
|
||||
- UK: Tebay Services (M6) — independent local food + Supercharger. Gretna Green — Supercharger + outlet shopping + cafe.
|
||||
- France: Aire de Beaune (A6) — Supercharger + regional bakery + wine country.
|
||||
- Germany: Autohof Lutterberg (A7) — Supercharger + traditional restaurant.
|
||||
- Netherlands: Schoonebeek — Supercharger near restaurant cluster.
|
||||
- Switzerland: St. Gallen — Supercharger + lakeside cafe.
|
||||
|
||||
Conversation history:
|
||||
${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
|
||||
|
||||
ASSISTANT:`;
|
||||
Respond with ONLY the JSON object.`;
|
||||
}
|
||||
|
||||
async chat(messages: ChatMessage[], itinerary: any, vehicle: string): Promise<GrokResponse> {
|
||||
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput): 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: vehicle.name, messageCount: messages.length }, '=== NEW CHAT REQUEST ===');
|
||||
const activeProvider = await this.getActiveProvider(requestId);
|
||||
|
||||
if (env.xaiApiKey) {
|
||||
log.info({ requestId }, 'Using real xAI API');
|
||||
if (activeProvider === 'xai') {
|
||||
return this.callXaiApi(messages, itinerary, vehicle, requestId);
|
||||
}
|
||||
|
||||
if (this.useFallback) {
|
||||
if (activeProvider === 'fallback') {
|
||||
return this.dumbFallback(messages, requestId);
|
||||
}
|
||||
|
||||
// LOCAL PERSONAL GROK CLI
|
||||
const prompt = this.buildPrompt(messages, itinerary, vehicle);
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-'));
|
||||
|
||||
const disallowed = env.nodeEnv === 'development'
|
||||
? 'search_replace,write_file,Agent,run_terminal_cmd'
|
||||
: 'run_terminal_cmd,search_replace,write_file,Agent';
|
||||
|
||||
try {
|
||||
const args = ['-p', prompt, '--output-format', 'json', '--yolo', '--disallowed-tools', 'run_terminal_cmd,search_replace,write_file,Agent', '--tools', 'web_search,web_fetch', '--max-turns', '6', '--effort', 'high', '--cwd', tmp];
|
||||
const args = [
|
||||
'-p', prompt,
|
||||
'--output-format', 'json',
|
||||
'--yolo',
|
||||
'--disallowed-tools', disallowed,
|
||||
'--tools', 'web_search,web_fetch',
|
||||
'--max-turns', '6',
|
||||
'--cwd', tmp,
|
||||
];
|
||||
|
||||
log.info({ requestId, bin: env.grokBin }, 'Spawning local authenticated grok CLI (pure JSON mode)');
|
||||
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(env.grokBin, args, { cwd: tmp, env: { ...process.env } });
|
||||
const child = spawn(env.grokBin, args, {
|
||||
cwd: tmp,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.on('data', d => stdout += d);
|
||||
child.on('close', code => code === 0 ? resolve(stdout) : reject(new Error(`grok exited ${code}`)));
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (d) => { stdout += d; });
|
||||
child.stderr.on('data', (d) => { stderr += d; });
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve(stdout);
|
||||
else {
|
||||
log.error({ requestId, code, stderr: stderr.slice(-800) }, 'Local grok CLI exited non-zero');
|
||||
reject(new Error(`grok exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
const data = JSON.parse(result);
|
||||
const data = JSON.parse(result) as { text?: string };
|
||||
const rawText = data.text || '';
|
||||
|
||||
const { cleanText, itinerary: parsed } = this.extractItineraryUpdate(rawText);
|
||||
|
||||
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText);
|
||||
log.info({ requestId, hasItinerary: !!parsed }, 'Local Grok CLI returned JSON response');
|
||||
return { text: cleanText, updatedItinerary: parsed };
|
||||
|
||||
} catch (err) {
|
||||
log.error({ requestId, err }, 'Local grok CLI failed — falling back to xAI API');
|
||||
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);
|
||||
}
|
||||
@@ -117,10 +211,9 @@ ASSISTANT:`;
|
||||
}
|
||||
}
|
||||
|
||||
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: string, requestId: string): Promise<GrokResponse> {
|
||||
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string): Promise<GrokResponse> {
|
||||
const prompt = this.buildPrompt(messages, itinerary, vehicle);
|
||||
|
||||
log.info({ requestId, promptLength: prompt.length }, 'Calling real xAI API');
|
||||
log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.x.ai/v1/chat/completions', {
|
||||
@@ -130,23 +223,22 @@ ASSISTANT:`;
|
||||
'Authorization': `Bearer ${env.xaiApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'grok-3',
|
||||
model: 'grok-4.3',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.7,
|
||||
temperature: 0.6,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
log.error({ requestId, status: response.status }, 'xAI API error');
|
||||
log.error({ requestId, status: response.status, body: text }, 'xAI API error');
|
||||
return this.dumbFallback(messages, requestId);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const rawText = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
const { cleanText, itinerary: parsed } = this.extractItineraryUpdate(rawText);
|
||||
|
||||
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText);
|
||||
return { text: cleanText, updatedItinerary: parsed };
|
||||
} catch (err) {
|
||||
log.error({ requestId, err }, 'xAI API call failed');
|
||||
@@ -154,26 +246,35 @@ ASSISTANT:`;
|
||||
}
|
||||
}
|
||||
|
||||
private extractItineraryUpdate(text: string): { cleanText: string; itinerary: any | null } {
|
||||
const upperText = text.toUpperCase();
|
||||
const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase());
|
||||
private parseGrokResponse(rawText: string): { text: string; itinerary: any | null } {
|
||||
try {
|
||||
const cleaned = rawText.trim().replace(/^```json\s*/, '').replace(/```$/, '').trim();
|
||||
const parsed = JSON.parse(cleaned);
|
||||
|
||||
if (sentinelIndex === -1) {
|
||||
return { cleanText: text.trim(), itinerary: null };
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return {
|
||||
text: parsed.message || parsed.reply || '',
|
||||
itinerary: parsed.itinerary || null,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn({ err: String(e), raw: rawText.slice(0, 300) }, 'Failed to parse Grok response as JSON');
|
||||
}
|
||||
|
||||
// Fallback to old sentinel method
|
||||
return this.extractItineraryUpdate(rawText);
|
||||
}
|
||||
|
||||
private extractItineraryUpdate(text: string): { text: string; itinerary: any | null } {
|
||||
const upperText = text.toUpperCase();
|
||||
const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase());
|
||||
if (sentinelIndex === -1) return { text: text.trim(), itinerary: null };
|
||||
|
||||
const after = text.substring(sentinelIndex + SENTINEL.length).trim();
|
||||
|
||||
// Try to find a JSON object, even if wrapped in ```json
|
||||
let jsonStart = after.indexOf('{');
|
||||
if (jsonStart === -1) return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
|
||||
// Find matching closing brace
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
if (jsonStart === -1) return { text: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
|
||||
let depth = 0, end = -1, inString = false, escape = false;
|
||||
for (let i = jsonStart; i < after.length; i++) {
|
||||
const ch = after[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
@@ -181,24 +282,17 @@ ASSISTANT:`;
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (!inString) {
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) { end = i; break; }
|
||||
}
|
||||
if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
}
|
||||
|
||||
if (end === -1) return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
if (end === -1) return { text: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
|
||||
const jsonStr = after.substring(jsonStart, end + 1);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const cleanText = text.substring(0, sentinelIndex).trim();
|
||||
return { cleanText, itinerary: parsed };
|
||||
return { text: text.substring(0, sentinelIndex).trim(), itinerary: parsed };
|
||||
} catch (e) {
|
||||
log.error({ err: e }, 'Failed to parse ITINERARY_UPDATE JSON');
|
||||
return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
return { text: text.substring(0, sentinelIndex).trim(), itinerary: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,10 +301,29 @@ ASSISTANT:`;
|
||||
if (['hi', 'hello', 'hey'].some(g => last.includes(g))) {
|
||||
return { text: "Hello! I'm Grok Drive. How can I help plan your UK or European Tesla trip today?", updatedItinerary: null };
|
||||
}
|
||||
return {
|
||||
text: "I'm ready to plan a great Tesla route for you across the UK and Europe. Tell me where you want to go!",
|
||||
updatedItinerary: null
|
||||
};
|
||||
return { text: "I'm ready to plan a great Tesla route for you across the UK and Europe. Tell me where you want to go!", updatedItinerary: null };
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
const localBinExists = await this.localBinaryExists();
|
||||
let provider: 'local' | 'xai' | 'fallback' = 'fallback';
|
||||
let label = 'Fallback';
|
||||
let detail = 'Basic responses only';
|
||||
|
||||
if (env.forceXaiApi && env.xaiApiKey) {
|
||||
provider = 'xai'; label = 'grok-4.3 API'; detail = 'Production path (xAI)';
|
||||
} else if (!env.grokEnabled) {
|
||||
provider = env.xaiApiKey ? 'xai' : 'fallback';
|
||||
} else if (localBinExists) {
|
||||
provider = 'local'; label = 'Local Heavy'; detail = 'Your authenticated Grok (free)';
|
||||
} else if (env.xaiApiKey) {
|
||||
provider = 'xai'; label = 'grok-4.3 API'; detail = 'Production path (xAI)';
|
||||
}
|
||||
return { provider, label, detail, isLocal: provider === 'local', model: provider === 'local' ? 'Heavy (personal)' : 'grok-4.3', bin: env.grokBin };
|
||||
}
|
||||
|
||||
private async localBinaryExists(): Promise<boolean> {
|
||||
try { await access(env.grokBin); return true; } catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
orgId: string | null;
|
||||
orgName: string | null;
|
||||
orgSlug: string | null;
|
||||
orgRole: string | null;
|
||||
isSuperAdmin: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user