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})`));