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:
parent
33ed5d9fe6
commit
af63297297
60
mock/app.js
60
mock/app.js
|
|
@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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, '<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 Google’s default 6)
|
// OSRM encodes with precision 5 (unlike Google’s 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(`There’s nothing to loosen on ${dayLabel()} right now — the day looks good.`);
|
} 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 {
|
} 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 <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 card’s <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);
|
||||||
|
|
|
||||||
|
|
@ -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})`));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user