diff --git a/mock/app.js b/mock/app.js
index 90af64e..1dbfffb 100644
--- a/mock/app.js
+++ b/mock/app.js
@@ -33,6 +33,58 @@ async function checkRouter() {
try { const r = await fetch(`/router-status?router=${routerKey()}`); routerOn = !!(await r.json()).router; }
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 (2–5 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, '
')
+ : '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)
// OSRM encodes with precision 5 (unlike Google’s default 6)
function decodePolyline(str, precision = 5) {
@@ -1339,6 +1391,8 @@ function handleUser(v) {
renderAll([st.id]);
ai(n.fix.msg || `Done — ${st.name} now runs ${n.fix.dur} min; what follows it slides later.`);
} else ai(`There’s 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 {
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 card’s alternatives or drag a candidate straight onto the plan.', 900);
}
@@ -1428,7 +1482,7 @@ function enterTrip(id) {
$('#daystrip').classList.remove('hidden');
$('#rail-foot').classList.remove('hidden');
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 = '';
scriptBusy = false;
legToken++; // drop in-flight route enrichment from the previous trip
@@ -1437,9 +1491,9 @@ function enterTrip(id) {
buildDayTabs();
renderHotelMks(); renderBaseChip();
// this trip may live on a different OSRM extract — re-check before routing
- checkRouter().then(() => {
+ Promise.all([checkRouter(), checkLLM()]).then(() => {
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 (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
diff --git a/mock/server.js b/mock/server.js
index 74c3241..1630091 100644
--- a/mock/server.js
+++ b/mock/server.js
@@ -21,6 +21,15 @@ const ROUTERS = {
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 PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' };
const CACHE = path.join(__dirname, '.tilecache');
@@ -98,6 +107,60 @@ http.createServer((req, res) => {
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 -------------------------------------------------------
let p = u.pathname === '/' ? '/index.html' : u.pathname;
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.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})`));