mock: wire the local LLM backend (llama.cpp Qwen3.8-27B-UD3-Q5-dual)

server.js: /llm-status probe + /llm-chat proxy to the local llama.cpp
OpenAI-compatible server (192.168.3.7:1234, model ctx 131072, 2 parallel
sessions). Model name, base URL and token budget pinned server-side
(LLM_BASE/LLM_MODEL/LLM_MAX_TOKENS env to override); non-streaming, since
the hidden thinking text shares max_tokens with the visible answer.

app.js: checkLLM() alongside checkRouter(); free-form chat now goes to the
model with a compact live plan snapshot (bookings, stays, per-day stops
with times) as system context. Deterministic UI intents (discovery
drawer, time nudges, swaps) stay local — the LLM proposes, the UI
commits. Canned-reply fallback when the server is down or the model is
not loaded; status badge now reads e.g. 'online · router · llm'.
This commit is contained in:
Greg Pomerantz 2026-09-09 10:19:39 -04:00
parent 33ed5d9fe6
commit af63297297
2 changed files with 121 additions and 4 deletions

View File

@ -33,6 +33,58 @@ async function checkRouter() {
try { const r = await fetch(`/router-status?router=${routerKey()}`); routerOn = !!(await r.json()).router; } try { const r = await fetch(`/router-status?router=${routerKey()}`); routerOn = !!(await r.json()).router; }
catch { routerOn = false; } catch { routerOn = false; }
} }
// ---------------- LLM client (local llama.cpp via the /llm-chat proxy) -----
// Free-form chat answers come from the model; deterministic UI intents
// (drawer discovery, time nudges, swaps) stay local — the LLM proposes,
// the user's UI commits. Falls back to canned replies when the server is
// down or the model isn't loaded.
let llmOn = false;
async function checkLLM() {
try { const r = await fetch('/llm-status'); const j = await r.json(); llmOn = !!j.llm; }
catch { llmOn = false; }
}
const badgeText = () => '📶 online' + (routerOn ? ' · router' : '') + (llmOn ? ' · llm' : '');
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Compact itinerary snapshot (the design doc's LLM context form): stops with
// times + stays + bookings. No geometry, no tool output. layout() fills
// s.start in place, so it is safe to call at any time.
function llmContext() {
const L = [`Trip: ${M.trip.title}`];
for (const b of M.trip.bookings)
L.push(`Booking (${b.type}): ${b.name}${b.ref ? ' — ref ' + b.ref : ''}${b.dates ? ', ' + b.dates : ''}${b.from ? `, ${b.from}${b.to}, ${b.date} dep ${b.depart}` : ''}`);
for (const st of stays)
L.push(`Stay: ${st.name} (${st.place || ''}) — ${st.checkIn}${st.checkOut}${st.price ? ', ' + st.price + ' €/night' : ''}`);
for (const d of M.days) {
const dd = days[d.id];
layout(dd);
const stops = dd.stops.filter(s => s.state === 'planned').map(
s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
L.push(`${dd.label} [${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
}
return L.join('\n');
}
function askLLM(v) {
const t = typing();
scriptBusy = true;
const sys = 'You are the planning assistant inside a trip-planning app. The user is refining a trip; the current plan state is below. ' +
'Answer the user\'s message concisely (25 sentences), using the plan as ground truth — never invent times, prices, or stops. ' +
'The user drives edits through the app UI; if an edit is implied, say which control does it (e.g. the chat command, the drawer chips on the map, a stop card\'s alternatives). ' +
'Plain text only, no markdown.\n\nCurrent plan:\n' + llmContext();
fetch('/llm-chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: v }] }),
})
.then(r => r.json())
.then(j => {
const c = (j.choices?.[0]?.message?.content || '').trim();
t.remove();
msg('ai', c ? esc(c).replace(/\n+/g, '<br>')
: 'The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
scriptBusy = false;
})
.catch(() => { t.remove(); msg('ai', 'LLM unreachable right now — falling back to canned replies.'); scriptBusy = false; });
}
// Google encoded-polyline decoder (OSRM `overview=full` geometry) // Google encoded-polyline decoder (OSRM `overview=full` geometry)
// OSRM encodes with precision 5 (unlike Googles default 6) // OSRM encodes with precision 5 (unlike Googles default 6)
function decodePolyline(str, precision = 5) { function decodePolyline(str, precision = 5) {
@ -1339,6 +1391,8 @@ function handleUser(v) {
renderAll([st.id]); renderAll([st.id]);
ai(n.fix.msg || `Done — ${st.name} now runs ${n.fix.dur} min; what follows it slides later.`); ai(n.fix.msg || `Done — ${st.name} now runs ${n.fix.dur} min; what follows it slides later.`);
} else ai(`Theres nothing to loosen on ${dayLabel()} right now — the day looks good.`); } else ai(`Theres nothing to loosen on ${dayLabel()} right now — the day looks good.`);
} else if (llmOn) {
askLLM(v); // free-form → LLM with the live plan as context
} else { } else {
ai('I can shift times, swap stops, or move things between days — e.g. “start the day later”. For finding, the drawer chips on the map (lunch / afternoon / hotel) are always there; for swapping, expand a stop cards <b>alternatives</b> or drag a candidate straight onto the plan.', 900); ai('I can shift times, swap stops, or move things between days — e.g. “start the day later”. For finding, the drawer chips on the map (lunch / afternoon / hotel) are always there; for swapping, expand a stop cards <b>alternatives</b> or drag a candidate straight onto the plan.', 900);
} }
@ -1428,7 +1482,7 @@ function enterTrip(id) {
$('#daystrip').classList.remove('hidden'); $('#daystrip').classList.remove('hidden');
$('#rail-foot').classList.remove('hidden'); $('#rail-foot').classList.remove('hidden');
offlineState = 'idle'; offlineState = 'idle';
const ob = $('#offline-badge'); if (ob) { ob.classList.remove('ok'); ob.textContent = routerOn ? '📶 online · router on' : '📶 online'; } const ob = $('#offline-badge'); if (ob) { ob.classList.remove('ok'); ob.textContent = badgeText(); }
$('#chat-body').innerHTML = ''; $('#chat-body').innerHTML = '';
scriptBusy = false; scriptBusy = false;
legToken++; // drop in-flight route enrichment from the previous trip legToken++; // drop in-flight route enrichment from the previous trip
@ -1437,9 +1491,9 @@ function enterTrip(id) {
buildDayTabs(); buildDayTabs();
renderHotelMks(); renderBaseChip(); renderHotelMks(); renderBaseChip();
// this trip may live on a different OSRM extract — re-check before routing // this trip may live on a different OSRM extract — re-check before routing
checkRouter().then(() => { Promise.all([checkRouter(), checkLLM()]).then(() => {
const b = $('#offline-badge'); const b = $('#offline-badge');
if (b && offlineState !== 'ready') b.textContent = routerOn ? '📶 online · router on' : '📶 online'; if (b && offlineState !== 'ready') b.textContent = badgeText();
if (routerOn) enrichLegs(day); if (routerOn) enrichLegs(day);
}); });
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day); if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);

View File

@ -21,6 +21,15 @@ const ROUTERS = {
probe: '-75.5478,10.3954;-75.5400,10.4020', // Cartagena, foot extract probe: '-75.5478,10.3954;-75.5400,10.4020', // Cartagena, foot extract
}, },
}; };
// LLM backend: local llama.cpp OpenAI-compatible server (see ~/.pi/agent/
// models.json). Model + budget are pinned server-side; the client only ever
// sends messages. Qwen3.8-27B-UD3-Q5-dual: --parallel 2, --ctx-size 131072.
const LLM = {
base: process.env.LLM_BASE || 'http://192.168.3.7:1234',
model: process.env.LLM_MODEL || 'Qwen3.8-27B-UD3-Q5-dual',
ctx: 131072,
maxTokens: +(process.env.LLM_MAX_TOKENS || 8192), // thinking + answer share this budget
};
const routerFor = (u) => ROUTERS[u.searchParams.get('router') || 'northeast'] || ROUTERS.northeast; const routerFor = (u) => ROUTERS[u.searchParams.get('router') || 'northeast'] || ROUTERS.northeast;
const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' }; const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' };
const CACHE = path.join(__dirname, '.tilecache'); const CACHE = path.join(__dirname, '.tilecache');
@ -98,6 +107,60 @@ http.createServer((req, res) => {
return; return;
} }
// ---- LLM backend (proxy -> local llama.cpp server) ----------------
// GET /llm-status — availability probe (mirrors /router-status)
if (u.pathname === '/llm-status') {
fetch(LLM.base + '/v1/models').then(r => r.json())
.then(j => {
const m = (j.data || []).find(x => x.id === LLM.model);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({
llm: !!(m && m.status && m.status.value === 'loaded'),
model: LLM.model, base: LLM.base, ctx: LLM.ctx,
status: (m && m.status && m.status.value) || 'unknown',
}));
})
.catch(() => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
res.writeHead(200); res.end(JSON.stringify({ llm: false, model: LLM.model, base: LLM.base, status: 'unreachable' }));
});
return;
}
// POST /llm-chat — body { messages, max_tokens?, temperature? }.
// Non-streaming; the model is a reasoning model, so the server-side budget
// must cover hidden thinking plus the visible answer.
if (u.pathname === '/llm-chat' && req.method === 'POST') {
let body = '';
req.on('data', c => { body += c; if (body.length > 2e6) req.destroy(); });
req.on('end', () => {
let in0;
try { in0 = JSON.parse(body); } catch { res.writeHead(400); return res.end('bad json'); }
const payload = {
model: LLM.model,
messages: Array.isArray(in0.messages) ? in0.messages : [],
max_tokens: in0.max_tokens || LLM.maxTokens,
temperature: in0.temperature ?? 0.7,
stream: false,
};
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
fetch(LLM.base + '/v1/chat/completions', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
}).then(async r => {
const t = await r.text();
res.writeHead(r.ok ? 200 : 502);
res.end(t);
}).catch(() => {
res.writeHead(502);
res.end(JSON.stringify({ error: 'LLM server unreachable at ' + LLM.base }));
});
});
return;
}
// ---- static ------------------------------------------------------- // ---- static -------------------------------------------------------
let p = u.pathname === '/' ? '/index.html' : u.pathname; let p = u.pathname === '/' ? '/index.html' : u.pathname;
const file = path.join(ROOT, p); const file = path.join(ROOT, p);
@ -107,4 +170,4 @@ http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' }); res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' });
res.end(d); res.end(d);
}); });
}).listen(PORT, '0.0.0.0', () => console.log(`map mock: http://localhost:${PORT} (tiles: /tiles/{z}/{x}/{y}.png)`)); }).listen(PORT, '0.0.0.0', () => console.log(`map mock: http://localhost:${PORT} (tiles: /tiles/{z}/{x}/{y}.png · llm: ${LLM.base} ${LLM.model})`));