feat: faster variant switching, live Grok thoughts, accurate trip endpoints
- Bump z-index on vehicle selector and modals (z-50 sat below Leaflet panes) - Prefetch the other route variants in the background as soon as the first trip lands; switching now hits the cache and is near-instant - Surface Grok's streaming thoughts to the UI: glassy overlay on the empty map + sidebar callout, with skeleton shimmer until the first thought - Thread explicit origin/destination from the TopBar through to the prompt as a ground-truth block; harden rules so the first/last stops match the user's actual endpoints and cross-Channel trips include both sides
This commit is contained in:
@@ -566,6 +566,16 @@ function LegRow({ leg }: { leg: Leg | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Skeleton shimmer ────────────────────────────────────────────────────────
|
||||
function SkeletonRow({ widthPct = 100 }: { widthPct?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="h-2.5 rounded animate-pulse"
|
||||
style={{ width: `${widthPct}%`, background: 'linear-gradient(90deg, var(--gd-border) 0%, var(--gd-border-2) 50%, var(--gd-border) 100%)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Variant strip ───────────────────────────────────────────────────────────
|
||||
const VARIANT_TONE: Record<RouteVariant['tone'], string> = {
|
||||
primary: 'var(--gd-red)',
|
||||
@@ -1341,6 +1351,7 @@ export default function TeslaTripPlanner() {
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [chips, setChips] = useState<string[]>([]);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [thinkingMessage, setThinkingMessage] = useState<string>('');
|
||||
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
|
||||
const [vehicle, setVehicle] = useState<Vehicle>(DEFAULT_VEHICLE);
|
||||
const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false);
|
||||
@@ -1404,6 +1415,55 @@ export default function TeslaTripPlanner() {
|
||||
return () => { cancelled = true; };
|
||||
}, [itinerary]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Prefetch other variants in the background ─────────────────────────────
|
||||
const prefetchedRef = React.useRef<Set<string>>(new Set());
|
||||
const prefetchKey = React.useMemo(() => {
|
||||
if (allStops.length < 2) return null;
|
||||
return `${allStops[0]?.name}__${allStops[allStops.length - 1]?.name}`;
|
||||
}, [allStops]);
|
||||
|
||||
// Drop cached variants whose journey doesn't match the current origin/destination.
|
||||
const lastPrefetchKey = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!prefetchKey) return;
|
||||
if (lastPrefetchKey.current && lastPrefetchKey.current !== prefetchKey) {
|
||||
setVariantCache(prev => {
|
||||
const next: typeof prev = {};
|
||||
if (prev[selectedVariant]) next[selectedVariant] = prev[selectedVariant];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
lastPrefetchKey.current = prefetchKey;
|
||||
}, [prefetchKey, selectedVariant]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!prefetchKey) return;
|
||||
if (thinking || variantSwitching) return;
|
||||
if (variants.length < 2) return;
|
||||
const targets: RouteVariant['id'][] = ['fast', 'scenic', 'cheap'];
|
||||
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
||||
if (!lastUserMsg) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
for (const id of targets) {
|
||||
if (cancelled) return;
|
||||
if (id === selectedVariant) continue;
|
||||
if (variantCache[id]) continue;
|
||||
const key = `${prefetchKey}::${id}`;
|
||||
if (prefetchedRef.current.has(key)) continue;
|
||||
prefetchedRef.current.add(key);
|
||||
try {
|
||||
await sendMessage(`Replan the trip as the ${id} variant`, { variant: id, silent: true, prefetch: true });
|
||||
} catch (err) {
|
||||
console.warn('[TeslaTrip] prefetch failed for', id, err);
|
||||
prefetchedRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [prefetchKey, variants.length, thinking, variantSwitching, selectedVariant]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const computedTotals = React.useMemo(() => {
|
||||
if (legs.length === 0) return null;
|
||||
const km = legs.reduce((s, l) => s + (l.distanceKm ?? 0), 0);
|
||||
@@ -1411,17 +1471,23 @@ export default function TeslaTripPlanner() {
|
||||
return { totalKm: km, driveMinutes: min };
|
||||
}, [legs]);
|
||||
|
||||
const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean } = {}) => {
|
||||
const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean; prefetch?: boolean } = {}) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
const variantToUse = opts.variant ?? selectedVariant;
|
||||
if (!opts.silent) {
|
||||
if (!opts.silent && !opts.prefetch) {
|
||||
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]);
|
||||
setChatInput('');
|
||||
setChips(prev => [...prev, trimmed].slice(-6));
|
||||
}
|
||||
if (opts.variant) setVariantSwitching(true);
|
||||
else setThinking(true);
|
||||
if (opts.prefetch) {
|
||||
// Background prefetch: never touch the visible itinerary or any spinner state.
|
||||
} else if (opts.variant) {
|
||||
setVariantSwitching(true);
|
||||
} else {
|
||||
setThinking(true);
|
||||
setThinkingMessage('');
|
||||
}
|
||||
|
||||
let lastPartialItinerary: any = null;
|
||||
let lastVariants: any[] | null = null;
|
||||
@@ -1438,6 +1504,8 @@ export default function TeslaTripPlanner() {
|
||||
itinerary,
|
||||
history: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
selectedVariant: variantToUse,
|
||||
origin: origin.trim() || undefined,
|
||||
destination: destination.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
|
||||
@@ -1466,10 +1534,13 @@ export default function TeslaTripPlanner() {
|
||||
try { payload = JSON.parse(evData); } catch { continue; }
|
||||
|
||||
if (evName === 'thinking') {
|
||||
// Could surface payload.message somewhere; for now we just show the existing spinner
|
||||
if (!opts.prefetch && typeof payload.message === 'string' && payload.message.trim()) {
|
||||
setThinkingMessage(payload.message.trim());
|
||||
}
|
||||
} else if (evName === 'partial') {
|
||||
lastPartialItinerary = payload.itinerary;
|
||||
if (Array.isArray(payload.variants)) lastVariants = payload.variants;
|
||||
if (!opts.prefetch) {
|
||||
// Use synchronous normalizer for partials — no geocoding, no blocking
|
||||
if (payload.itinerary) {
|
||||
setItinerary(normalizePartialItinerary(payload.itinerary));
|
||||
@@ -1477,17 +1548,21 @@ export default function TeslaTripPlanner() {
|
||||
if (Array.isArray(payload.variants)) {
|
||||
setVariants(normalizeVariants(payload.variants));
|
||||
}
|
||||
}
|
||||
} else if (evName === 'done') {
|
||||
finalReply = payload.reply || '';
|
||||
if (payload.itinerary) {
|
||||
const clean = await normalizeAndSanitizeItinerary(payload.itinerary);
|
||||
setItinerary(clean);
|
||||
const variantJustRendered = typeof payload.selectedVariant === 'string'
|
||||
? payload.selectedVariant as RouteVariant['id']
|
||||
: opts.variant ?? selectedVariant;
|
||||
setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } }));
|
||||
lastSelectedVariant = variantJustRendered;
|
||||
if (!opts.prefetch) {
|
||||
setItinerary(clean);
|
||||
}
|
||||
}
|
||||
if (!opts.prefetch) {
|
||||
if (Array.isArray(payload.variants)) {
|
||||
setVariants(normalizeVariants(payload.variants));
|
||||
}
|
||||
@@ -1496,28 +1571,32 @@ export default function TeslaTripPlanner() {
|
||||
} else if (opts.variant) {
|
||||
setSelectedVariant(opts.variant);
|
||||
}
|
||||
}
|
||||
} else if (evName === 'error') {
|
||||
throw new Error(payload.error || 'Stream error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.silent && finalReply) {
|
||||
if (!opts.silent && !opts.prefetch && finalReply) {
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: finalReply }]);
|
||||
}
|
||||
if (lastPartialItinerary && !opts.silent) {
|
||||
if (lastPartialItinerary && !opts.silent && !opts.prefetch) {
|
||||
toast.success('Grok finished your route');
|
||||
} else if (opts.variant && lastSelectedVariant) {
|
||||
} else if (opts.variant && !opts.prefetch && lastSelectedVariant) {
|
||||
toast.success(`Switched to ${lastSelectedVariant} route`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[TeslaTrip] Grok stream failed:', err);
|
||||
if (!opts.silent) {
|
||||
if (!opts.silent && !opts.prefetch) {
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
|
||||
}
|
||||
} finally {
|
||||
if (!opts.prefetch) {
|
||||
setThinking(false);
|
||||
setVariantSwitching(false);
|
||||
setThinkingMessage('');
|
||||
}
|
||||
}
|
||||
|
||||
// touch unused refs so eslint stays quiet (we keep them as breadcrumbs)
|
||||
@@ -1847,6 +1926,50 @@ export default function TeslaTripPlanner() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thinking overlay on the map (when Grok is planning but nothing rendered yet) */}
|
||||
{allStops.length === 0 && thinking && (
|
||||
<div className="absolute inset-0 grid place-items-center pointer-events-none">
|
||||
<div
|
||||
className="text-center max-w-md px-6 py-5 rounded-2xl pointer-events-auto"
|
||||
style={{
|
||||
background: 'rgba(20,20,24,0.86)',
|
||||
border: '1px solid var(--gd-border-2)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
|
||||
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
|
||||
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
|
||||
</div>
|
||||
<div className="text-[11px] tracking-[0.15em] uppercase" style={{ color: 'var(--gd-red)' }}>
|
||||
Grok is planning your route
|
||||
</div>
|
||||
</div>
|
||||
{thinkingMessage ? (
|
||||
<div
|
||||
key={thinkingMessage}
|
||||
className="text-[13px] leading-snug min-h-[40px]"
|
||||
style={{ color: 'var(--gd-text-2)', fontStyle: 'italic' }}
|
||||
>
|
||||
“{thinkingMessage}”
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 min-h-[40px]">
|
||||
<SkeletonRow widthPct={92} />
|
||||
<SkeletonRow widthPct={78} />
|
||||
<SkeletonRow widthPct={85} />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10.5px] mt-3 tracking-wider uppercase" style={{ color: 'var(--gd-text-3)' }}>
|
||||
Stops will appear as soon as they're chosen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stops rail */}
|
||||
@@ -1978,13 +2101,33 @@ export default function TeslaTripPlanner() {
|
||||
)}
|
||||
|
||||
{thinking && (
|
||||
<div className="mt-4 flex items-center gap-2 px-2 py-2 rounded-lg" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
|
||||
<div className="mt-4 px-3 py-2.5 rounded-lg" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
|
||||
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
|
||||
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
|
||||
</div>
|
||||
<div className="text-[11px] tracking-wider" style={{ color: 'var(--gd-red)' }}>GROK IS PLANNING YOUR ROUTE...</div>
|
||||
<div className="text-[10px] tracking-wider uppercase" style={{ color: 'var(--gd-red)' }}>
|
||||
{itinerary.days.length > 0 ? 'Refining your route' : 'Grok is planning your route'}
|
||||
</div>
|
||||
</div>
|
||||
{thinkingMessage && (
|
||||
<div
|
||||
className="text-[12px] leading-snug mt-1.5 line-clamp-3"
|
||||
style={{ color: 'var(--gd-text-2)', fontStyle: 'italic' }}
|
||||
key={thinkingMessage}
|
||||
>
|
||||
{thinkingMessage}
|
||||
</div>
|
||||
)}
|
||||
{!thinkingMessage && itinerary.days.length === 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<SkeletonRow widthPct={92} />
|
||||
<SkeletonRow widthPct={78} />
|
||||
<SkeletonRow widthPct={85} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2053,8 +2196,8 @@ function ModalShell({
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 grid place-items-center p-6"
|
||||
style={{ background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
|
||||
className="fixed inset-0 grid place-items-center p-6"
|
||||
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -2519,8 +2662,8 @@ function AddDetourOverlay({
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex justify-center items-start px-6 pt-[90px]"
|
||||
style={{ background: 'rgba(5,5,8,0.6)', backdropFilter: 'blur(6px)' }}
|
||||
className="fixed inset-0 flex justify-center items-start px-6 pt-[90px]"
|
||||
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.6)', backdropFilter: 'blur(6px)' }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -2819,10 +2962,11 @@ function VehicleSelectorPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={onClose} />
|
||||
<div
|
||||
className="fixed z-50 overflow-hidden"
|
||||
className="fixed overflow-hidden"
|
||||
style={{
|
||||
zIndex: 9999,
|
||||
left, top, width: panelWidth, maxHeight: '78vh',
|
||||
background: 'rgba(20,20,24,0.96)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
|
||||
@@ -13,6 +13,8 @@ const ChatRequestSchema = z.object({
|
||||
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(),
|
||||
});
|
||||
|
||||
router.post('/chat', async (req, res) => {
|
||||
@@ -28,7 +30,7 @@ router.post('/chat', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
}
|
||||
|
||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast' } = parsed.data;
|
||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
|
||||
|
||||
log.info({
|
||||
requestId,
|
||||
@@ -37,6 +39,8 @@ router.post('/chat', async (req, res) => {
|
||||
historyLength: history.length,
|
||||
currentItineraryDays: itinerary?.days?.length || 0,
|
||||
selectedVariant,
|
||||
origin,
|
||||
destination,
|
||||
}, 'Parsed chat request');
|
||||
|
||||
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
|
||||
@@ -45,6 +49,7 @@ router.post('/chat', async (req, res) => {
|
||||
itinerary,
|
||||
vehicle,
|
||||
selectedVariant,
|
||||
{ origin, destination },
|
||||
);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
@@ -86,7 +91,7 @@ router.post('/chat/stream', async (req, res) => {
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() });
|
||||
}
|
||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast' } = parsed.data;
|
||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
@@ -119,6 +124,7 @@ router.post('/chat/stream', async (req, res) => {
|
||||
itinerary,
|
||||
vehicle,
|
||||
selectedVariant,
|
||||
{ origin, destination },
|
||||
);
|
||||
for await (const ev of stream) {
|
||||
if (cancelled) break;
|
||||
|
||||
@@ -160,19 +160,23 @@ export class GrokHeadlessClient {
|
||||
}
|
||||
}
|
||||
|
||||
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast') {
|
||||
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}) {
|
||||
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`
|
||||
: '';
|
||||
|
||||
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.
|
||||
|
||||
Selected route variant: ${selectedVariant.toUpperCase()}
|
||||
${variantBrief}
|
||||
|
||||
Current vehicle: ${vehicleName(vehicle)}
|
||||
Current vehicle: ${vehicleName(vehicle)}${odBlock}
|
||||
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.
|
||||
@@ -294,6 +298,10 @@ Respond with **only** a single valid JSON object in exactly this format. No text
|
||||
|
||||
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.
|
||||
@@ -355,21 +363,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, selectedVariant: string = 'fast'): Promise<GrokResponse> {
|
||||
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise<GrokResponse> {
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
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, selectedVariant);
|
||||
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, selectedVariant);
|
||||
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-'));
|
||||
|
||||
const disallowed = env.nodeEnv === 'development'
|
||||
@@ -420,7 +428,7 @@ Respond with ONLY the JSON object.`;
|
||||
} 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, selectedVariant);
|
||||
return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts);
|
||||
}
|
||||
return this.dumbFallback(messages, requestId);
|
||||
} finally {
|
||||
@@ -432,7 +440,7 @@ Respond with ONLY the JSON object.`;
|
||||
* 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'): AsyncGenerator<StreamEvent> {
|
||||
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): AsyncGenerator<StreamEvent> {
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
|
||||
|
||||
@@ -442,7 +450,7 @@ Respond with ONLY the JSON object.`;
|
||||
// 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)
|
||||
? await this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts)
|
||||
: await this.dumbFallback(messages, requestId);
|
||||
yield {
|
||||
type: 'done',
|
||||
@@ -454,7 +462,7 @@ Respond with ONLY the JSON object.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant);
|
||||
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'
|
||||
@@ -488,6 +496,9 @@ Respond with ONLY the JSON object.`;
|
||||
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;
|
||||
@@ -574,7 +585,21 @@ Respond with ONLY the JSON object.`;
|
||||
}
|
||||
}
|
||||
} else if (ev.type === 'thought' && typeof ev.data === 'string') {
|
||||
// Optional: surface short snippets of Grok's thinking
|
||||
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' };
|
||||
@@ -604,8 +629,8 @@ Respond with ONLY the JSON object.`;
|
||||
}
|
||||
}
|
||||
|
||||
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast'): Promise<GrokResponse> {
|
||||
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant);
|
||||
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): 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 {
|
||||
|
||||
Reference in New Issue
Block a user