Fix arrival/return transfer legs + plan restore + router guards
Transfers (airport<->hotel) were the app's weakest surface; this fixes the long-standing cluster of bugs in one pass: - saneRoute(): reject OSRM results implausible for the straight-line distance (extract coverage holes once produced a 9h 'route' for a 5km taxi hop); applied at every routePts call site - healTransfers() on load: re-sync transfer endpoints against current hotel + AIRPORTS table, re-sync stays[] against the day-base hotel objects (the saved stays[] carried pre-coord-fix locations, and dayBase() resolves the hotel FROM stays — every return leg was routed to the stale hotel), drop implausible or endpoint-drifted stored routes so they re-route cleanly, default a missing mode to car, and recover arrLandingMin from the booked flight time - the last-stop->hotel ride is now a first-class editable leg (retLeg): the rail row opens the route card with drag + walk/taxi toggle, exactly like stop-to-stop legs; previously auto-routed returns seed it - startMin now actually tracks landing + drop-off (syncArrivalStart had a null arrLandingMin guard that silently froze the day's start) - applySavedPlan re-points day: days becomes a new object tree on restore, and the current-day reference stayed on the stale base-plan day — the rail rendered the base plan instead of the saved one until a day-tab click - setDay enriches the opened day (idempotent), so a day opened from a saved plan gets its legs (incl. the return leg) routed instead of sitting on straight-line estimates Also in this change set: correct LLM model name, keyword-router removal (chat goes to the LLM), screenshot_map + relocate_stop tools for the UI LLM, server-side plan store with localStorage migration, thinking toggle + keep-alive warm-up, three-state LLM guard with proactive model load, spatial /near /nearest /corridor SQL fixes, and the full data.js coordinate correction for the Colombia demo.
This commit is contained in:
parent
df5fed691b
commit
1ba39f7333
507
mock/app.js
507
mock/app.js
|
|
@ -25,9 +25,24 @@ const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_
|
|||
// matching OSRM profile; out of coverage → straight-line estimate at the
|
||||
// mode's average speed). Airport↔hotel transfers are always by car.
|
||||
let travelMode = 'foot'; // 'foot' | 'car' — loaded per-app in enterTrip
|
||||
let thinkOn = true; // LLM reasoning: on = deliberate thinking (better planning, slower) · off = no_think (quick, faster)
|
||||
try { thinkOn = (localStorage.getItem('trips.think') !== 'off'); } catch {}
|
||||
const legSpeedKmh = m => (m === 'car' ? 26 : WALK_KMH); // urban taxi incl. traffic/parking
|
||||
const estLegMin = (m, a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (legSpeedKmh(m) / 60)));
|
||||
|
||||
// The OSRM extract only covers the trip area. A point outside the coverage — or a route
|
||||
// through a coverage hole — comes back wildly longer than the straight line (a 5 km
|
||||
// airport drop-off once “took 9 hours by sea”). Such a “route” is worse than the estimate,
|
||||
// so treat it as no route.
|
||||
function saneRoute(res, pts, mode) {
|
||||
if (!res) return null;
|
||||
let km = 0;
|
||||
for (let i = 1; i < pts.length; i++) km += haversine(pts[i - 1], pts[i]) / 1000;
|
||||
const est = Math.max(4, km / legSpeedKmh(mode || 'foot') * 60);
|
||||
if (res.durMin > Math.max(150, est * 6)) return null;
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------------- route client (local OSRM via the /route proxy) ----------
|
||||
// Legs start life as straight-line estimates (conf 1) and are upgraded in the
|
||||
// background to real road-network times + geometry (conf 3) when the point
|
||||
|
|
@ -46,11 +61,68 @@ async function checkRouter() {
|
|||
// (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;
|
||||
let llmOn = false, llmState = 'unknown'; // 'loaded' | 'unloaded' | 'unreachable' | 'unknown' (from /llm-status)
|
||||
let tripActive = false; // true once the user has entered a trip (startApp → enterTrip)
|
||||
async function checkLLM() {
|
||||
try { const r = await fetch('/llm-status'); const j = await r.json(); llmOn = !!j.llm; }
|
||||
catch { llmOn = false; }
|
||||
try { const r = await fetch('/llm-status'); const j = await r.json(); llmState = j.status || 'unknown'; llmOn = (llmState === 'loaded'); }
|
||||
catch { llmOn = false; llmState = 'unreachable'; }
|
||||
}
|
||||
// Re-probe model state every 20s so the guard + badge stay accurate without a
|
||||
// page reload: after a llama-server restart the model is 'unloaded' (router
|
||||
// still up), and once a request auto-loads it we want to notice it flipped to
|
||||
// 'loaded'.
|
||||
setInterval(async () => {
|
||||
await checkLLM();
|
||||
try { const b = $('#offline-badge'); if (b && offlineState !== 'ready') b.textContent = badgeText(); } catch {}
|
||||
// Proactively LOAD a cold model. After a llama-server restart (or if the
|
||||
// router evicted it on idle) the model is 'unloaded' but the router is up —
|
||||
// the old code did nothing here, so the user's first message paid the full
|
||||
// cold load. Loading it in the background means the next turn lands warm.
|
||||
if (llmState === 'unloaded' && !scriptBusy && tripActive)
|
||||
ensureLLM().then(ok => { if (ok) warmLLM(); }); // once loaded, warm the KV prefix too
|
||||
}, 20000);
|
||||
// Wake the model when the router is up but the model isn't loaded. The
|
||||
// llama.cpp router auto-loads a registered model on the first inference
|
||||
// request (models_autoload=true), so a tiny request triggers the load. The
|
||||
// old code never sent a request in this state, so the model could not be
|
||||
// loaded by the app at all — the user just saw "offline". Resolves true once
|
||||
// the model reports 'loaded'.
|
||||
let loadingPromise = null; // in-flight model load — every caller joins it, none duplicate
|
||||
async function ensureLLM() {
|
||||
if (llmOn) return true;
|
||||
if (loadingPromise) return loadingPromise; // a load is in flight — wait for it to finish
|
||||
if (llmState !== 'unloaded') { await checkLLM(); return llmOn; }
|
||||
loadingPromise = (async () => {
|
||||
try {
|
||||
await fetch('/llm-chat', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0, reasoning_effort: 'none' }),
|
||||
signal: AbortSignal.timeout(240000), // a cold 27B load can take a couple of minutes
|
||||
});
|
||||
} catch {}
|
||||
await checkLLM();
|
||||
return llmOn;
|
||||
})();
|
||||
try { return await loadingPromise; }
|
||||
finally { loadingPromise = null; }
|
||||
}
|
||||
// Warm-up / keep-alive: the local model's FIRST call after idle pays a full
|
||||
// cold prompt-process (measured ~7–10× slower than warm — a 238s first turn vs
|
||||
// ~38s for the next one). A max_tokens=1 probe carrying the live system prompt
|
||||
// keeps the GPU + KV prefix warm, so the user's first real turn lands at warm
|
||||
// speed instead of the cold outlier. Re-warmed every 2 min while the tab is open.
|
||||
let warming = false;
|
||||
async function warmLLM() {
|
||||
if (!llmOn || warming) return;
|
||||
warming = true;
|
||||
try {
|
||||
const sys = (M && typeof llmSystemPrompt === 'function') ? llmSystemPrompt() : 'You are a trip-planning assistant. Ready when you are.';
|
||||
await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: 'ready' }], max_tokens: 1, temperature: 0, reasoning_effort: 'none' }) });
|
||||
} catch {}
|
||||
warming = false;
|
||||
}
|
||||
setInterval(() => { if (llmOn && !scriptBusy) warmLLM(); }, 120000); // keep the model warm while the tab is open
|
||||
// ---------------- web search client (SearXNG via the /search proxy) ------
|
||||
// Live facts (opening hours, fees, seasonal notes) with provenance URLs —
|
||||
// the third leg of the 3-source blend. Enrichment APPENDS to suggestion
|
||||
|
|
@ -339,6 +411,84 @@ function healCoordinates() {
|
|||
}
|
||||
return changed;
|
||||
}
|
||||
// Repair the arrival/departure ground transfers of a saved plan. A plan saved
|
||||
// before the coordinate fix — or after a transfer waypoint was dragged outside the
|
||||
// router's coverage — can still carry stale endpoints and a route the router computed
|
||||
// for those (a “9-hour taxi” for a 5 km airport drop-off). Re-sync the endpoints against
|
||||
// the current hotels + the AIRPORTS table, drop any implausible stored route so it
|
||||
// re-routes cleanly, default a missing mode, and make sure the arrival day's start
|
||||
// actually tracks the drop-off. Idempotent — a consistent plan is a no-op.
|
||||
function healTransfers() {
|
||||
let changed = false;
|
||||
const fixOne = (d, t, base) => {
|
||||
if (!d || !t) return;
|
||||
const fl = d.arrivalFlight || d.departureFlight;
|
||||
const ap = fl && airportOf(fl.code);
|
||||
const hotel = base && base.at;
|
||||
if (ap && t.at && (t.at[0] !== ap.at[0] || t.at[1] !== ap.at[1])) { t.at = [ap.at[0], ap.at[1]]; changed = true; }
|
||||
if (hotel && t.hotelAt && (t.hotelAt[0] !== hotel[0] || t.hotelAt[1] !== hotel[1])) {
|
||||
t.hotelAt = [hotel[0], hotel[1]];
|
||||
if (base.name) t.hotelName = base.name;
|
||||
changed = true;
|
||||
}
|
||||
if (!t.mode || (t.mode !== 'foot' && t.mode !== 'car')) { t.mode = 'car'; changed = true; }
|
||||
const est = (t.at && t.hotelAt) ? (driveMin({ at: t.at }, t.hotelAt) || 60) : 60;
|
||||
// a stored route that is implausibly long (coverage-hole garbage), or an unfinished
|
||||
// drag, goes back to the estimate so it is re-routed for the corrected endpoints
|
||||
const garbage = (t.geometry && t.dur > Math.max(90, est * 3)) || (t._pts && !t.geometry);
|
||||
if (garbage) {
|
||||
t.dur = est; t.conf = 2; t.geometry = null; delete t._pts; t._routedMode = null; changed = true;
|
||||
}
|
||||
// a stored route whose ends no longer touch the (corrected) endpoints was computed
|
||||
// for stale coordinates — same treatment
|
||||
if (t.geometry && t.geometry.length && ap && hotel) {
|
||||
const isIn = !!d.arrivalFlight;
|
||||
const eStart = isIn ? ap.at : hotel, eEnd = isIn ? hotel : ap.at;
|
||||
if (haversine(t.geometry[0], eStart) > 300 || haversine(t.geometry[t.geometry.length - 1], eEnd) > 300) {
|
||||
t.dur = est; t.conf = 2; t.geometry = null; delete t._pts; t._routedMode = null; changed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
// dayBase() resolves each day's hotel from stays[], but an older saved stays[] can
|
||||
// carry pre-fix hotel coordinates while the (already corrected) per-day base objects
|
||||
// hold the current ones — re-sync stays against the day bases before fixing anything
|
||||
const baseBy = new Map();
|
||||
Object.values(days).forEach(dd => {
|
||||
if (dd.base && dd.base.at && dd.base.name) baseBy.set(dd.base.name, dd.base.at);
|
||||
});
|
||||
stays.forEach(s => {
|
||||
const want = baseBy.get(s.name);
|
||||
if (want && s.at && (s.at[0] !== want[0] || s.at[1] !== want[1])) { s.at = [want[0], want[1]]; changed = true; }
|
||||
});
|
||||
const ids = Object.keys(days).sort();
|
||||
if (!ids.length) return false;
|
||||
// a saved return leg (retLeg) may have been routed to a pre-fix hotel location — if its
|
||||
// geometry ends anywhere else than the current hotel point, drop it so it re-routes
|
||||
ids.forEach(id => {
|
||||
const dd = days[id];
|
||||
const leg = dd.retLeg;
|
||||
if (!leg || !leg.geometry || !leg.geometry.length) return;
|
||||
const h = dd.base && dd.base.at;
|
||||
if (h && haversine(leg.geometry[leg.geometry.length - 1], h) > 300) {
|
||||
const lastSt = dd.stops[dd.stops.length - 1];
|
||||
leg.dur = (lastSt && h) ? estLegMin(leg.mode || travelMode, lastSt.at, h) : leg.dur;
|
||||
leg.conf = 1; leg.geometry = null; delete leg._routedMode; changed = true;
|
||||
}
|
||||
});
|
||||
const first = days[ids[0]], last = days[ids[ids.length - 1]];
|
||||
const h0 = (first.base && first.base.at) ? first.base : (stays[0] || null);
|
||||
const h1 = (last.base && last.base.at) ? last.base : (stays[stays.length - 1] || null);
|
||||
fixOne(first, first && first.transferIn, h0);
|
||||
fixOne(last, last && last.transferOut, h1);
|
||||
// a null arrLandingMin (older saves) means the arrival day's start never followed the
|
||||
// drop-off when its duration changed — derive it from the booked flight time
|
||||
if (first && first.isArrival && first.arrivalFlight && first.arrLandingMin == null) {
|
||||
const lm = parseHM(first.arrivalFlight.time);
|
||||
if (lm != null) { first.arrLandingMin = lm; changed = true; }
|
||||
}
|
||||
if (changed && first && first.isArrival && first.arrLandingMin != null && first.transferIn) syncArrivalStart(first);
|
||||
return changed;
|
||||
}
|
||||
function rangeStr(a, b) {
|
||||
const f = s => { const [y, mo, da] = s.split('-').map(Number); return da + ' ' + MONTHS[mo - 1]; };
|
||||
return a && b ? (a === b ? f(a) : f(a) + '–' + f(b)) : '';
|
||||
|
|
@ -462,13 +612,33 @@ const TOOLS = {
|
|||
if (!B) return { error: `cannot resolve "to" = "${a.to}"` };
|
||||
if (A.id === B.id || A.name === B.name) return { from: A.name, to: B.name, minutes: 0, km: 0, source: 'same place' };
|
||||
let minutes, km, source;
|
||||
const res = await routePts([A.at, B.at], 'foot');
|
||||
const res = saneRoute(await routePts([A.at, B.at], 'foot'), [A.at, B.at], 'foot');
|
||||
if (res) { minutes = res.durMin; km = Math.round(res.distance / 100) / 10; source = 'local router (OSRM walk)'; }
|
||||
else { minutes = walkMin(A.at, B.at); km = Math.round(haversine(A.at, B.at) / 100) / 10;
|
||||
source = routerOn ? 'straight-line est (outside router coverage)' : 'straight-line est (router offline)'; }
|
||||
return { from: A.name, to: B.name, minutes, km, source };
|
||||
},
|
||||
|
||||
// The model's EYES: capture exactly what the user sees on the map for the
|
||||
// current day — raster tiles + route legs + the numbered pins — as a PNG
|
||||
// that is attached to this tool result (the model is multimodal). Use it
|
||||
// when the user describes something visual ("the marker is in the water",
|
||||
// "what do I see?"), and after relocate_stop to verify the fix.
|
||||
screenshot_map: () => {
|
||||
const url = mapScreenshot();
|
||||
if (!url) return { error: 'could not capture the map (no tiles loaded yet) — ask the user to describe what they see instead' };
|
||||
const cur = days[curDay];
|
||||
return {
|
||||
ok: true,
|
||||
day: cur.label + ' · ' + cur.date,
|
||||
zoom: map.getZoom(),
|
||||
pins: cur.stops.map((s, i) => `${i + 1} = ${s.name} [${s.state}] at ${s.at[0].toFixed(4)},${s.at[1].toFixed(4)}`),
|
||||
hotels: staysForDay().map(o => 'H = ' + o.name + ' at ' + o.at[0].toFixed(4) + ',' + o.at[1].toFixed(4)),
|
||||
legend: 'numbered pins are this day’s stops in day order (red=planned, grey=idea/backup); H = hotel; green line = the route the user sees. The attached image is the actual map viewport.',
|
||||
image: url,
|
||||
};
|
||||
},
|
||||
|
||||
review_plan: (a) => {
|
||||
const ids = a.dayId ? [a.dayId] : Object.keys(days);
|
||||
const issues = [];
|
||||
|
|
@ -543,6 +713,25 @@ const TOOLS = {
|
|||
return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) };
|
||||
},
|
||||
|
||||
// Re-pin a stop at real-world coordinates. The dataset coordinates can be
|
||||
// wrong (a pin 2–4 km off, or in the water) — when that happens, find the
|
||||
// place's TRUE position first (poi_search/poi_near by name), then fix it
|
||||
// here. Legs re-route and the edit is undo-able like every other fix-op.
|
||||
relocate_stop: (a) => {
|
||||
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||||
const lat = +a.lat, lng = +a.lng;
|
||||
if (!isFinite(lat) || !isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return { error: 'lat/lng must be valid decimal degrees' };
|
||||
const old = stop.at.slice();
|
||||
stop.at = [lat, lng];
|
||||
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
|
||||
stop.suggested = { ...(stop.suggested || {}), value: [lat, lng], conf: 4, src: 'assistant' };
|
||||
rebuildLegs(d);
|
||||
commit(`[llm] relocated ${stop.name} to ${lat.toFixed(5)}, ${lng.toFixed(5)}`);
|
||||
renderAll([stop.id]);
|
||||
return { ok: true, action: 'relocate_stop', stop: stop.name, from: old, to: [lat, lng], day: d.label,
|
||||
note: 'legs re-routed from the new pin; call screenshot_map if you want to confirm it sits where the named place actually is' };
|
||||
},
|
||||
|
||||
// Re-anchor the whole trip around a real booked flight (arrival + departure).
|
||||
// Deterministic: re-dates the day window, records the flights as hard-anchor
|
||||
// bookings, shortens the arrival day (start after landing+transfer) and the
|
||||
|
|
@ -691,11 +880,14 @@ function llmSystemPrompt() {
|
|||
' move_stop(stopId, toDayId) — move a stop to another day. stopId is a stop id or its name.\n' +
|
||||
' set_duration(stopId, minutes) — change a stop\'s duration in minutes. stopId is a stop id or its name.\n' +
|
||||
' set_start(dayId, time) — set a day\'s start time as "HH:MM".\n' +
|
||||
' relocate_stop(stopId, lat, lng) — re-pin a stop at real-world coordinates. The dataset coordinates can be WRONG (pin 2–4 km off, in the water). When a pin looks misplaced: find the true position first with poi_search/poi_near (search by the place\'s name — use the OSM result\'s lat/lng, never your own memory), then relocate_stop, then screenshot_map to confirm.\n' +
|
||||
' screenshot_map() — a photo of the map exactly as the user sees it (tiles, route line, numbered pins; H = hotel). You CAN see the image it returns. Call it when the user describes something visual ("the marker is in the sea", "what do I see?", "does this look right?"), after relocate_stop, or whenever you are unsure about geometry.\n' +
|
||||
' apply_flight_anchors({arrival, departure}) — re-anchor the WHOLE trip around real booked flights. arrival/departure = {date:"YYYY-MM-DD", time:"HH:MM" local, airport:"code or name e.g. CTG", city, from?, to?, flight?, ref?}. Re-dates the day window, records the flights, shortens the arrival day (start after landing+transfer) and the departure day (wrap before the flight), and trims stops that no longer fit. Use this when the user provides their actual ticket/booking.\n\n' +
|
||||
'Guidelines:\n' +
|
||||
'- dayId is the day id like "D1" or "D2" — shown in brackets in the plan rows, e.g. [D1 · 2026-09-12]. Never use the calendar date as a dayId.\n' +
|
||||
'- Answer concisely (1–4 sentences), plain text, no markdown.\n' +
|
||||
'- For any factual question about times, distances, or prices, CALL the tool — do not guess. For CURRENT facts ("is it open?", "how much now", seasonal), prefer web_search and cite its source URLs.\n' +
|
||||
'- Visual complaints ("marker in the water", "point is in the wrong place", "route crosses the bay"): believe the user, call screenshot_map to see it yourself, find the true coordinates with poi_search, fix with relocate_stop, confirm with screenshot_map. NEVER dismiss a placement complaint as a rendering glitch.\n' +
|
||||
'- For a requested change, call the matching fix-op, then confirm in one sentence what changed. The user can undo any edit.\n' +
|
||||
'- Before a big rework, consider review_plan() and mention any issues it reports.\n' +
|
||||
'- Be efficient: call each tool at most once unless you need to, and never re-fetch facts you already have. A fix-op alone is enough — do not re-search or re-fetch the stop you are moving/editing.\n' +
|
||||
|
|
@ -729,7 +921,8 @@ async function askLLM(v) {
|
|||
try {
|
||||
let finalText = null;
|
||||
for (let i = 0; i < LLM_TOOL_MAX; i++) {
|
||||
const r = await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages, max_tokens: 8192 }) });
|
||||
const llmBody = { messages, max_tokens: 8192 }; if (!thinkOn) llmBody.reasoning_effort = 'none';
|
||||
const r = await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(llmBody) });
|
||||
const j = await r.json();
|
||||
const content = (j.choices?.[0]?.message?.content || '').trim();
|
||||
const call = parseToolBlock(content);
|
||||
|
|
@ -745,7 +938,17 @@ async function askLLM(v) {
|
|||
if ((call.name === 'poi_near' || call.name === 'poi_search') && res && Array.isArray(res.results) && res.results.length) {
|
||||
showSpatialPins(res.results, call.name);
|
||||
}
|
||||
// screenshot tools return an image — attach it as a vision payload so
|
||||
// the model can actually see the map, with the text fields alongside
|
||||
if (res && res.image) {
|
||||
const { image, ...rest } = res;
|
||||
messages.push({ role: 'user', content: [
|
||||
{ type: 'text', text: '[tool result for ' + call.name + ']\n' + JSON.stringify(rest) },
|
||||
{ type: 'image_url', image_url: { url: image } },
|
||||
]});
|
||||
} else {
|
||||
messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
finalText = content; break;
|
||||
|
|
@ -807,7 +1010,7 @@ async function enrichLegs(d = day) {
|
|||
const t = d.transferIn, want = t.mode || 'car';
|
||||
if (!(t.conf === 3 && t.geometry && t._routedMode === want)) {
|
||||
const before = t.dur;
|
||||
const res = await routePts([t.at, t.hotelAt], want);
|
||||
const res = saneRoute(await routePts([t.at, t.hotelAt], want), [t.at, t.hotelAt], want);
|
||||
if (res) { t.dur = res.durMin; t.conf = 3; t.geometry = res.geometry; t._routedMode = want; }
|
||||
else { t.conf = 1; }
|
||||
if (t.dur !== before) syncArrivalStart(d); // the arrival day starts after this drop-off
|
||||
|
|
@ -820,7 +1023,8 @@ async function enrichLegs(d = day) {
|
|||
if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue;
|
||||
// legs touching a transit stop are fixed (flight/taxi/bus), never re-routed
|
||||
if (a.kind === 'transit' || b.kind === 'transit') continue;
|
||||
const res = await routePts([a.at, b.at], d.legs[i].mode === 'multimodal' ? 'foot' : d.legs[i].mode);
|
||||
const legMode = d.legs[i].mode === 'multimodal' ? 'foot' : d.legs[i].mode;
|
||||
const res = saneRoute(await routePts([a.at, b.at], legMode), [a.at, b.at], legMode);
|
||||
if (legTokens[key] !== token) return; // this day was re-enriched mid-flight
|
||||
const leg = d.legs[i];
|
||||
if (res) {
|
||||
|
|
@ -833,13 +1037,22 @@ async function enrichLegs(d = day) {
|
|||
if (d === day) renderAll();
|
||||
}
|
||||
}
|
||||
// return-to-hotel leg when a single stay anchors the day (uses the travel mode)
|
||||
// return-to-hotel ride when a single stay anchors the day — an editable leg (retLeg),
|
||||
// so the rail row can be clicked into the route card (drag the drop-off, walk vs taxi)
|
||||
if (!d.isDeparture && d.stops.length && staysForDay(d).length === 1) {
|
||||
const last = d.stops[d.stops.length - 1];
|
||||
if (last.kind !== 'region' && last.kind !== 'transit') {
|
||||
const res = await routePts([last.at, dayBase(d).at], travelMode);
|
||||
const leg = getReturnLeg(d);
|
||||
if (leg) {
|
||||
const want = leg.mode;
|
||||
if (!(leg.conf === 3 && leg.geometry && leg._routedMode === want)) {
|
||||
const res = saneRoute(await routePts([last.at, dayBase(d).at], want), [last.at, dayBase(d).at], want);
|
||||
if (legTokens[key] !== token) return;
|
||||
if (res) { d.retRouted = res; if (d === day) renderAll(); }
|
||||
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = want; }
|
||||
else { leg.conf = 1; leg.src = 'estimate — outside router coverage'; }
|
||||
if (d === day) renderAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -935,12 +1148,33 @@ const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus:
|
|||
|
||||
// ---------------- layout / legs ----------------
|
||||
// every day ends by returning to the hotel anchor
|
||||
// The last-stop→hotel return ride as a first-class leg (like the arrival drop-off):
|
||||
// one object per day that the route card can edit — drag the drop-off point, toggle
|
||||
// walk vs taxi. A previously auto-routed return (retRouted) seeds it so the card opens
|
||||
// on the real geometry. Multi-stay days (or a region/transit last stop) have no single
|
||||
// drop-off to edit, so no leg.
|
||||
function getReturnLeg(d = day) {
|
||||
if (d.isDeparture || !d.stops.length || staysForDay(d).length !== 1) return null;
|
||||
const last = d.stops[d.stops.length - 1];
|
||||
if (last.kind === 'region' || last.kind === 'transit') return null;
|
||||
const h = dayBase(d) && dayBase(d).at;
|
||||
if (!h) return null;
|
||||
if (!d.retLeg) {
|
||||
const mode = d.retMode || travelMode;
|
||||
d.retLeg = d.retRouted
|
||||
? { mode, dur: d.retRouted.durMin, conf: 3, geometry: d.retRouted.geometry }
|
||||
: { mode, dur: estLegMin(mode, last.at, h), conf: 1 };
|
||||
delete d.retMode;
|
||||
}
|
||||
return d.retLeg;
|
||||
}
|
||||
function returnDur(d = day) {
|
||||
if (d.isDeparture) return 0; // departure day: the train is the end of the day
|
||||
const last = d.stops[d.stops.length - 1];
|
||||
if (!last) return 0;
|
||||
// a single stay anchored the day and the router has a real walk → use it
|
||||
if (d.retRouted && staysForDay(d).length === 1 && last.kind !== 'transit') return d.retRouted.durMin;
|
||||
// the return ride is an editable leg when a single stay anchors the day
|
||||
const leg = d.retLeg;
|
||||
if (leg && staysForDay(d).length === 1 && last.kind !== 'transit' && last.kind !== 'region') return leg.dur;
|
||||
const list = staysForDay(d);
|
||||
// worst case across every candidate stay for these nights (equals the real leg when one)
|
||||
return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (legSpeedKmh(travelMode) / 60)));
|
||||
|
|
@ -954,7 +1188,10 @@ function rebuildLegs(d = day, enrich = true) {
|
|||
const prevModes = (d.legs || []).map(g => (g && (g.mode === 'foot' || g.mode === 'car')) ? g.mode : null); // keep each leg's walk/taxi choice across rebuilds
|
||||
const pm = i => prevModes[i] || travelMode;
|
||||
d.legs = [];
|
||||
const prevRet = d.retLeg && (d.retLeg.mode === 'foot' || d.retLeg.mode === 'car') ? d.retLeg.mode : null; // keep the return ride's choice too
|
||||
delete d.retRouted;
|
||||
delete d.retLeg;
|
||||
if (prevRet) d.retMode = prevRet;
|
||||
for (let i = 0; i < d.stops.length - 1; i++) {
|
||||
const a = d.stops[i], b = d.stops[i + 1];
|
||||
// a vague (region) stop at either end → worst-case leg until refined
|
||||
|
|
@ -1043,7 +1280,7 @@ function renderMap() {
|
|||
}
|
||||
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
|
||||
staysForDay(obj).forEach(o => {
|
||||
const g = (obj.retRouted && staysForDay(obj).length === 1) ? obj.retRouted.geometry : [pts[pts.length - 1], o.at];
|
||||
const g = (obj.retLeg && staysForDay(obj).length === 1 && obj.retLeg.geometry) ? obj.retLeg.geometry : [pts[pts.length - 1], o.at];
|
||||
retLine = L.polyline(g, { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map);
|
||||
otherLayers.push(retLine);
|
||||
retLines.push(retLine);
|
||||
|
|
@ -1082,6 +1319,58 @@ function renderMap() {
|
|||
map.fitBounds(L.latLngBounds([dayBase().at, ...stayPts, ...pts]), pad);
|
||||
}
|
||||
}
|
||||
// Composite the map viewport exactly as the user sees it (tiles + current
|
||||
// day's route + numbered pins) into a PNG data-URL. Feeds the screenshot_map
|
||||
// tool so the multimodal model can SEE layout problems (pin in the bay, line
|
||||
// crossing water) instead of the user having to describe them.
|
||||
function mapScreenshot() {
|
||||
const cont = map.getContainer();
|
||||
const cr = cont.getBoundingClientRect();
|
||||
if (!cr.width || !cr.height) return null;
|
||||
const scale = Math.min(1, 1024 / cr.width); // cap tokens: ≤1024px wide
|
||||
const W = Math.round(cr.width * scale), H = Math.round(cr.height * scale);
|
||||
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.fillStyle = '#dfe3ea'; cx.fillRect(0, 0, W, H); // unloaded-tile background
|
||||
try {
|
||||
// 1) the raster tiles currently in the DOM (same-origin → canvas untainted)
|
||||
cont.querySelectorAll('img.leaflet-tile').forEach(img => {
|
||||
const r = img.getBoundingClientRect();
|
||||
cx.drawImage(img, (r.left - cr.left) * scale, (r.top - cr.top) * scale, r.width * scale, r.height * scale);
|
||||
});
|
||||
// 2) current day's route legs — real routed geometry when available
|
||||
const cur = days[curDay];
|
||||
const pts = cur.stops.map(s => s.at);
|
||||
cx.lineCap = 'round'; cx.lineJoin = 'round';
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const g = cur.legs[i]?.geometry || [pts[i], pts[i + 1]];
|
||||
cx.beginPath();
|
||||
g.forEach((p, k) => { const q = map.latLngToContainerPoint(p).multiplyBy(scale); k ? cx.lineTo(q.x, q.y) : cx.moveTo(q.x, q.y); });
|
||||
cx.strokeStyle = 'rgba(46,158,91,.9)'; cx.lineWidth = 4 * scale; cx.stroke();
|
||||
}
|
||||
// 3) numbered pins — same order and numbering as the on-screen markers
|
||||
const pin = (p, label, bg) => {
|
||||
const q = map.latLngToContainerPoint(p).multiplyBy(scale);
|
||||
cx.beginPath(); cx.arc(q.x, q.y, 11 * scale, 0, Math.PI * 2);
|
||||
cx.fillStyle = bg; cx.fill();
|
||||
cx.lineWidth = 2 * scale; cx.strokeStyle = '#fff'; cx.stroke();
|
||||
cx.fillStyle = '#fff'; cx.font = `bold ${Math.round(12 * scale)}px sans-serif`;
|
||||
cx.textAlign = 'center'; cx.textBaseline = 'middle';
|
||||
cx.fillText(label, q.x, q.y + 0.5 * scale);
|
||||
};
|
||||
cur.stops.forEach((s, i) => pin(s.at, String(i + 1), s.state === 'planned' ? '#b5533c' : '#9aa3b2'));
|
||||
staysForDay().forEach(o => pin(o.at, 'H', '#2f6fd6'));
|
||||
// 4) legend so the model knows what it is looking at
|
||||
cx.fillStyle = 'rgba(255,255,255,.93)';
|
||||
cx.fillRect(8 * scale, 8 * scale, 300 * scale, 38 * scale);
|
||||
cx.fillStyle = '#1c2230'; cx.textAlign = 'left'; cx.textBaseline = 'alphabetic';
|
||||
cx.font = `bold ${Math.round(13 * scale)}px sans-serif`;
|
||||
cx.fillText(`${cur.label} · ${cur.date} · zoom ${map.getZoom().toFixed(1)}`, 14 * scale, 24 * scale);
|
||||
cx.font = `${Math.round(11 * scale)}px sans-serif`;
|
||||
cx.fillText('pins = day stops in order · H = hotel · green = route', 14 * scale, 40 * scale);
|
||||
return cv.toDataURL('image/png');
|
||||
} catch (e) { console.warn('[screenshot]', e); return null; }
|
||||
}
|
||||
function highlightStop(id, on) {
|
||||
const m = markers[id]; if (!m) return;
|
||||
m.getElement()?.style?.setProperty('transform', on ? 'scale(1.25)' : '');
|
||||
|
|
@ -1462,13 +1751,20 @@ function renderArrivalHeader(body) {
|
|||
// the ride back to the hotel at the end of a (non-departure) day
|
||||
function renderReturnRow(body) {
|
||||
const multi = staysForDay().length > 1;
|
||||
const ret = el('div', 'leg-row', `<span class="leg-ico">${MODE_ICO[travelMode] || '•'}</span><span>back to ${dayBase() ? dayBase().name : 'hotel'}</span> `);
|
||||
ret.append(tchip(returnDur(), multi ? 1 : 3));
|
||||
const leg = getReturnLeg(day);
|
||||
const ret = el('div', 'leg-row', `<span class="leg-ico">${MODE_ICO[(leg && leg.mode) || travelMode] || '•'}</span><span>back to ${dayBase() ? dayBase().name : 'hotel'}</span> `);
|
||||
ret.append(tchip(returnDur(), multi ? 1 : (leg ? leg.conf : 3)));
|
||||
if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
|
||||
else if (leg) ret.title = 'Click to edit the ride — drag the drop-off, walk or taxi';
|
||||
const on = { weight: 7, opacity: 1, color: '#4a5160', dashArray: null };
|
||||
const off = { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' };
|
||||
ret.addEventListener('mouseenter', () => retLines.forEach(l => l.setStyle(on)));
|
||||
ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off)));
|
||||
// exactly like any stop-to-stop leg: click → route card with drag + walk/taxi toggle
|
||||
if (leg) ret.addEventListener('click', () => {
|
||||
const last = day.stops[day.stops.length - 1];
|
||||
openLegEditor(leg, last.at, dayBase().at, last.name + ' → ' + (dayBase() ? dayBase().name : 'hotel'), 'return');
|
||||
});
|
||||
body.append(ret);
|
||||
}
|
||||
|
||||
|
|
@ -1913,7 +2209,7 @@ function openLegEditor(leg, from, to, title, kind, idx) {
|
|||
box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1));
|
||||
renderBudget(); railSoon();
|
||||
const t = ++rt;
|
||||
routePts(leg._pts, leg.mode).then(res => {
|
||||
routePts(leg._pts, leg.mode).then(r => { const res = saneRoute(r, leg._pts, leg.mode);
|
||||
if (t !== rt || !l3 || l3.leg !== leg) return;
|
||||
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; line.setLatLngs(res.geometry); }
|
||||
box.innerHTML = 'leg time: ' + (res ? '' : 'est. ');
|
||||
|
|
@ -1979,7 +2275,7 @@ function setLegMode(leg, m) {
|
|||
renderRail(); renderL3Mode(leg);
|
||||
if (l3.kind === 'arrival') renderMap(); // keep the drop-off line colour (walk=green, taxi=blue) in sync
|
||||
const t = (leg._rt = (leg._rt || 0) + 1);
|
||||
routePts(leg._pts || [l3.from, l3.to], m).then(res => {
|
||||
routePts(leg._pts || [l3.from, l3.to], m).then(r => { const res = saneRoute(r, leg._pts || [l3.from, l3.to], m);
|
||||
if (leg._rt !== t || !l3 || l3.leg !== leg) return;
|
||||
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = m; }
|
||||
if (l3.kind === 'arrival') syncArrivalStart(day); // and again once the real route lands
|
||||
|
|
@ -2178,7 +2474,10 @@ function buildDayTabs() {
|
|||
}
|
||||
function setDay(id) {
|
||||
curDay = id; day = days[id];
|
||||
if (!day.legs.length) rebuildLegs(day);
|
||||
// route this day's legs in the background (idempotent — already-routed legs are
|
||||
// skipped); without this a day opened from a saved plan would sit on straight-line
|
||||
// estimates (notably the new return leg) until something else enriched it
|
||||
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
|
||||
document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', dayList()[i] && dayList()[i].id === id));
|
||||
closeL3(); // a selected route from another day is stale
|
||||
tempClear();
|
||||
|
|
@ -2324,32 +2623,40 @@ function handleUser(v) {
|
|||
}
|
||||
if (/find|looking|want|search|options|another|different|discover/.test(s) && slotWord) { openDiscover(slotWord[1]); return; }
|
||||
if (/find|looking|want|search|options|another|different|discover/.test(s) && /activit|afternoon|things to do/.test(s)) { openDiscover('visit'); return; }
|
||||
if (/compare/.test(s)) {
|
||||
ai('Tap the <b>⚖</b> on any candidate, stop card, or suggestion row to line up 2–4 options side by side in the comparison tray at the bottom — travel from the anchor, to the hotel, price, and time, all in one glance.', 900);
|
||||
// Everything else goes to the model. There are NO keyword intercepts for
|
||||
// questions or edits any more: “where should I eat lunch?”, “move X to after
|
||||
// lunch”, “your marker is in the sea” all need the LLM — and the model has
|
||||
// real tools for all of them (web/POI search, routing, plan fix-ops, and a
|
||||
// map screenshot so it can see what the user sees).
|
||||
// THREE states, not two. The old code refused as soon as the model wasn't
|
||||
// 'loaded', so a freshly-restarted llama server (model registered but not
|
||||
// loaded) could never be woken up: the request never reached the router,
|
||||
// its models_autoload never fired, and the user just saw "offline".
|
||||
if (llmOn) { askLLM(v); return; }
|
||||
if (llmState === 'unloaded') {
|
||||
// Router is up, model isn't loaded: load it, then handle the request.
|
||||
ai('The planning model isn’t loaded yet — sending your request now, which loads it. The first turn after a server restart takes a minute or two.', 900);
|
||||
ensureLLM().then(ok => {
|
||||
if (ok) askLLM(v);
|
||||
else ai('The planning model is offline right now, so I can’t take free-form requests. I can still open the browse drawer — try “find a lunch spot” or “show hotels” — and every card on the map has alternatives you can swap in directly.', 900);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (s.includes('later') || s.includes('morning')) {
|
||||
day.startMin += 30;
|
||||
commit('start day 30 min later');
|
||||
renderAll(day.stops.map(x => x.id));
|
||||
ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at <b>${fmt(layout())}</b>.`);
|
||||
} else if (s.includes('lunch') || s.includes('longer')) {
|
||||
const n = activeNudges().find(x => x.fix?.stop && x.fix?.dur);
|
||||
const st = n ? stopById(n.fix.stop) : null;
|
||||
if (n && !n.fixed && st) {
|
||||
n.fixed = true; st.dur = n.fix.dur;
|
||||
commit(`nudge: ${n.fix.label}`);
|
||||
rebuildLegs(day);
|
||||
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 <b>alternatives</b> or drag a candidate straight onto the plan.', 900);
|
||||
}
|
||||
ai('The planning model is offline right now, so I can’t take free-form requests. I can still open the browse drawer — try “find a lunch spot” or “show hotels” — and every card on the map has alternatives you can swap in directly.', 900);
|
||||
}
|
||||
$('#chat-send').onclick = chatSend;
|
||||
// LLM thinking toggle — near the send box. on = deliberate (better planning),
|
||||
// off = quick answers. Persisted per browser, like the travel-mode preference.
|
||||
function renderThinkToggle() {
|
||||
const b = $('#think-toggle'); if (!b) return;
|
||||
b.classList.toggle('on', thinkOn);
|
||||
b.textContent = thinkOn ? '💭 on' : '💭 off';
|
||||
b.title = thinkOn
|
||||
? 'LLM thinking ON — the model reasons before answering (better planning, slower). Click for quick answers.'
|
||||
: 'LLM thinking OFF — fast, direct answers (no hidden reasoning). Click to let it think.';
|
||||
}
|
||||
$('#think-toggle').onclick = () => { thinkOn = !thinkOn; try { localStorage.setItem('trips.think', thinkOn ? 'on' : 'off'); } catch {}; renderThinkToggle(); };
|
||||
renderThinkToggle();
|
||||
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
|
||||
|
||||
// ---- flight/booking ingestion: drop a real ticket, the model re-anchors ----
|
||||
|
|
@ -2466,8 +2773,11 @@ if (auto) setTimeout(() => { startApp(); if (auto !== 'plan' && matchMedia('(max
|
|||
|
||||
// ---------------- multi-trip: registry, per-trip persistence, switching --------
|
||||
// All trips live in window.TRIPS. Each trip's working state (days/stays +
|
||||
// version history) is persisted to localStorage under its own key, so several
|
||||
// trips can be worked on at once and each resumes exactly where it stopped.
|
||||
// version history) is persisted per trip. The SINGLE SOURCE OF TRUTH is the
|
||||
// server copy (~/trips/store/mock/<trip>/plan.json, via /trip-store/<id>/plan)
|
||||
// — the same home as docs and chat. localStorage is now only a local cache
|
||||
// (offline + instant paint); a first visit with an old localStorage-only plan
|
||||
// is migrated up to the server on load.
|
||||
let tripId = window.MOCK_CITY;
|
||||
const tripStore = {
|
||||
key: id => 'tripstate.v1.' + id,
|
||||
|
|
@ -2475,14 +2785,31 @@ const tripStore = {
|
|||
load(id) { try { const s = localStorage.getItem(this.key(id)); return s ? JSON.parse(s) : null; } catch { return null; } },
|
||||
};
|
||||
let persistT = 0;
|
||||
function planPayload() {
|
||||
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25
|
||||
return { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title, savedAt: Date.now() };
|
||||
}
|
||||
function persistTrip(force = false) {
|
||||
clearTimeout(persistT);
|
||||
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25
|
||||
// docs + chat live on the server now (~/trips/store/mock/<trip>/) — localStorage
|
||||
// only carries the plan state. force: skip the debounce — the re-anchoring
|
||||
// of the whole trip around booked flights is the one edit we must never lose
|
||||
const save = () => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title });
|
||||
if (force) save(); else persistT = setTimeout(save, 250);
|
||||
// force: skip the debounce — the re-anchoring of the whole trip around
|
||||
// booked flights is the one edit we must never lose
|
||||
const push = () => {
|
||||
const p = planPayload();
|
||||
tripStore.save(tripId, p); // local cache (offline fallback)
|
||||
fetch('/trip-store/' + encodeURIComponent(tripId) + '/plan', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p),
|
||||
}).then(r => r.json()).then(j => { if (!j.ok) console.warn('[plan] server save failed:', j.error); }).catch(() => {});
|
||||
};
|
||||
if (force) push(); else persistT = setTimeout(push, 250);
|
||||
}
|
||||
function loadServerPlan(id) {
|
||||
// {state:'saved',plan} | {state:'none'} (server says no plan yet) | {state:'error'}
|
||||
// — 'error' (server unreachable) is deliberately distinct, so a transient
|
||||
// failure never makes the app believe a saved trip is brand new.
|
||||
return fetch('/trip-store/' + encodeURIComponent(id) + '/plan')
|
||||
.then(r => r.json())
|
||||
.then(j => (j && j.ok && j.plan) ? { state: 'saved', plan: j.plan } : { state: 'none' })
|
||||
.catch(() => ({ state: 'error' }));
|
||||
}
|
||||
function renderTripMenu() {
|
||||
$('#crumb').innerHTML = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;
|
||||
|
|
@ -2500,9 +2827,24 @@ function renderTripMenu() {
|
|||
more.innerHTML = `<span class="ti-name">+ New trip…</span><span class="ti-sub">start from a booking on the landing screen</span>`;
|
||||
more.onclick = () => location.href = location.pathname;
|
||||
menu.append(more);
|
||||
const ex = el('button', 'trip-item');
|
||||
ex.innerHTML = `<span class="ti-name">⬇ Export plan (JSON)</span><span class="ti-sub">download the server-side plan file</span>`;
|
||||
ex.onclick = () => {
|
||||
menu.classList.add('hidden');
|
||||
fetch('/trip-store/' + encodeURIComponent(tripId) + '/plan').then(r => r.json()).then(j => {
|
||||
const p = (j.ok && j.plan) ? j.plan : planPayload(); // fall back to live state
|
||||
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = tripId + '-plan-v' + p.version + '.json';
|
||||
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
}).catch(() => ai('Couldn’t fetch the plan file from the server.', 400));
|
||||
};
|
||||
menu.append(ex);
|
||||
}
|
||||
function enterTrip(id) {
|
||||
tripId = id;
|
||||
tripActive = true; // on a trip now → the app may proactively keep the model loaded/warm
|
||||
M = window.TRIPS[id];
|
||||
days = {}; M.days.forEach(d => days[d.id] = deep(d));
|
||||
curDay = 'D1'; day = days[curDay];
|
||||
|
|
@ -2510,24 +2852,19 @@ function enterTrip(id) {
|
|||
try { const tm = localStorage.getItem('trips.travemode'); if (tm === 'car' || tm === 'foot') travelMode = tm; } catch {}
|
||||
stays = deep(M.trip.stays);
|
||||
staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1);
|
||||
// resume this trip's saved state (edits + version history), if any
|
||||
const saved = tripStore.load(id);
|
||||
if (saved) {
|
||||
days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx;
|
||||
if (saved.bookings) M.trip.bookings = deep(saved.bookings);
|
||||
if (saved.title != null) M.trip.title = saved.title;
|
||||
// self-heal plans saved by the old re-anchoring, which re-dated the days
|
||||
// but not the stays (hotel nights then no longer "covered" any day);
|
||||
// idempotent — a consistent plan is a no-op
|
||||
redateStaysAndPlaces();
|
||||
healTravelAnchors(); // rebuild the arrival/departure airport cards + transfers
|
||||
// re-sync coordinates against the corrected source dataset — a plan saved
|
||||
// before the old-city/airport coordinate fix would otherwise still hold the
|
||||
// misplaced (in-the-bay) points. idempotent: an in-sync plan is a no-op
|
||||
const coordFixed = healCoordinates();
|
||||
if (coordFixed) Object.values(days).forEach(d => rebuildLegs(d, false)); // re-estimate legs from the new points
|
||||
persistTrip(coordFixed);
|
||||
// resume this trip's saved state (edits + version history), if any.
|
||||
// Server copy is authoritative; the localStorage cache is the fallback and
|
||||
// is migrated up on first contact, so an old local-only plan is not lost.
|
||||
const local = tripStore.load(id);
|
||||
loadServerPlan(id).then(res => {
|
||||
let saved = res.state === 'saved' ? res.plan : null;
|
||||
if (!saved && local) { // no server copy (or it's unreachable) → local cache
|
||||
saved = local;
|
||||
if (res.state === 'none') persistTrip(true); // one-time migration up to the server
|
||||
}
|
||||
if (saved) applySavedPlan(saved);
|
||||
else { commit('initial plan'); script1(); } // genuinely new trip
|
||||
});
|
||||
tripDocs = {}; // rehydrated from the server store below
|
||||
// reset per-trip UI state
|
||||
chatLog = []; compareItems = [];
|
||||
|
|
@ -2553,16 +2890,45 @@ function enterTrip(id) {
|
|||
if (b && offlineState !== 'ready') b.textContent = badgeText();
|
||||
if (routerOn) enrichLegs(day);
|
||||
if (webOn && $('#sugg-block')) renderSugg(); // web came up late → enrich the suggestion rows now
|
||||
// Get the model ready for the first turn: already loaded → warm it;
|
||||
// unloaded (fresh restart / router eviction) → LOAD it in the background
|
||||
// so the user's first message isn't a cold start.
|
||||
if (llmOn) warmLLM();
|
||||
else if (llmState === 'unloaded' && !scriptBusy)
|
||||
ensureLLM().then(ok => { if (ok) warmLLM(); }); // load it, then warm the KV prefix
|
||||
});
|
||||
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
|
||||
renderAll();
|
||||
renderVersionPill();
|
||||
if (!saved) {
|
||||
commit('initial plan');
|
||||
script1();
|
||||
} else {
|
||||
ai(`Welcome back to <b>${M.trip.title}</b> — you’re at <b>v${version}</b> (“${history[hIdx].label}”). Your earlier edits are kept; switch trips any time from the menu, top-left.`, 600);
|
||||
// NOTE: the saved-state branch (welcome-back vs script1 onboarding) runs in
|
||||
// loadServerPlan()'s .then → applySavedPlan(), because the plan now lives on
|
||||
// the server. Until it resolves (~50 ms locally) the UI shows the base plan.
|
||||
}
|
||||
function applySavedPlan(saved) {
|
||||
days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx;
|
||||
// days is a NEW object tree — re-point the current-day reference, which enterTrip()
|
||||
// already set to the (now stale) base-plan day; without this the rail would render
|
||||
// the base plan instead of the saved one until the user clicked a day tab
|
||||
curDay = days[curDay] ? curDay : 'D1';
|
||||
day = days[curDay];
|
||||
if (saved.bookings) M.trip.bookings = deep(saved.bookings);
|
||||
if (saved.title != null) M.trip.title = saved.title;
|
||||
// self-heal plans saved by the old re-anchoring, which re-dated the days
|
||||
// but not the stays (hotel nights then no longer "covered" any day);
|
||||
// idempotent — a consistent plan is a no-op
|
||||
redateStaysAndPlaces();
|
||||
healTravelAnchors(); // rebuild the arrival/departure airport cards + transfers
|
||||
// re-sync coordinates against the corrected source dataset — a plan saved
|
||||
// before the old-city/airport coordinate fix would otherwise still hold the
|
||||
// misplaced (in-the-bay) points. idempotent: an in-sync plan is a no-op
|
||||
const coordFixed = healCoordinates();
|
||||
const transferFixed = healTransfers(); // re-sync transfer endpoints, drop implausible stored routes
|
||||
if (coordFixed) Object.values(days).forEach(d => rebuildLegs(d, false)); // re-estimate legs from the new points
|
||||
persistTrip(coordFixed || transferFixed);
|
||||
buildDayTabs(); renderHotelMks(); renderModeToggle(); renderDocChips(); renderTripMenu();
|
||||
renderAll(); renderVersionPill();
|
||||
if (transferFixed && day) enrichLegs(day); // re-route the repaired transfers (and the day's legs) in the background
|
||||
ai(`Welcome back to <b>${M.trip.title}</b> — you’re at <b>v${version}</b> (“${history[hIdx].label}”). Your earlier edits are kept; switch trips any time from the menu, top-left.`, 600);
|
||||
}
|
||||
function switchTrip(id) {
|
||||
if (id === tripId) return;
|
||||
|
|
@ -2602,6 +2968,7 @@ function setTravelMode(m) {
|
|||
const d = days[md.id]; if (!d) return;
|
||||
(d.legs || []).forEach(g => { if (g && (g.mode === 'foot' || g.mode === 'car' || g.vague)) g.mode = m; });
|
||||
if (d.transferIn && d.transferIn.at) { d.transferIn.mode = m; d.transferIn._routedMode = null; }
|
||||
if (d.retLeg) { d.retLeg.mode = m; d.retLeg._routedMode = null; } // the return ride follows the global choice (rebuildLegs below carries the mode)
|
||||
rebuildLegs(d, false);
|
||||
});
|
||||
renderAll();
|
||||
|
|
|
|||
84
mock/data.js
84
mock/data.js
|
|
@ -532,9 +532,9 @@ const COLOMBIA = {
|
|||
version: 1,
|
||||
title: 'Colombia · Cartagena → Santa Marta 21–28 Sep',
|
||||
stays: [
|
||||
{ id: 'ST1', name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522], place: 'Getsemaní, Cartagena',
|
||||
{ id: 'ST1', name: 'Hotel Casa San Antonio', at: [10.42050, -75.54650], place: 'Getsemaní, Cartagena',
|
||||
checkIn: '2026-09-21', checkOut: '2026-09-24', state: 'booked', price: 120 },
|
||||
{ id: 'ST2', name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130], place: 'Centro, Santa Marta',
|
||||
{ id: 'ST2', name: 'Hotel Muelle de Bolívar', at: [11.23950, -74.21550], place: 'Centro, Santa Marta',
|
||||
checkIn: '2026-09-25', checkOut: '2026-09-28', state: 'booked', price: 150 },
|
||||
],
|
||||
places: [
|
||||
|
|
@ -547,9 +547,9 @@ const COLOMBIA = {
|
|||
],
|
||||
bookings: [
|
||||
{ type: 'hotel', name: 'Hotel Casa San Antonio', ref: 'C-20117',
|
||||
dates: '21–25 Sep', where: [10.3952, -75.5522], status: 'booked', source: 'user_booking' },
|
||||
dates: '21–25 Sep', where: [10.42050, -75.54650], status: 'booked', source: 'user_booking' },
|
||||
{ type: 'hotel', name: 'Hotel Muelle de Bolívar', ref: 'C-20118',
|
||||
dates: '25–28 Sep', where: [11.2390, -74.2130], status: 'booked', source: 'user_booking' },
|
||||
dates: '25–28 Sep', where: [11.23950, -74.21550], status: 'booked', source: 'user_booking' },
|
||||
{ type: 'flight', name: 'AV 1351', ref: 'AV-88213',
|
||||
from: 'Rafael Núñez (CTG)', to: 'Simón Bolívar (SMR)', date: '2026-09-25',
|
||||
depart: '08:40', arrive: '09:25', status: 'booked', source: 'user_booking',
|
||||
|
|
@ -563,16 +563,16 @@ const COLOMBIA = {
|
|||
days: [
|
||||
{ id: 'D1', date: '2026-09-21', label: 'Day 1 · Mon 21',
|
||||
startMin: 9 * 60, wakingHours: 12,
|
||||
base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] },
|
||||
base: { name: 'Hotel Casa San Antonio', at: [10.42050, -75.54650] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S1', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [10.3952, -75.5522], dur: 30, durConf: 3,
|
||||
at: [10.42050, -75.54650], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S2', name: 'Getsemaní street-art walk', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://en.wikipedia.org/wiki/Getseman%C3%AD,_Cartagena',
|
||||
at: [10.3932, -75.5520], dur: 60, durConf: 3,
|
||||
at: [10.42000, -75.54550], dur: 60, durConf: 3,
|
||||
suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' },
|
||||
price: null,
|
||||
img: ['🎨', 'Getsemaní street art', 'linear-gradient(135deg,#c98a3c,#8a5c24)'],
|
||||
|
|
@ -581,7 +581,7 @@ const COLOMBIA = {
|
|||
links: [ { label: 'Getsemaní', href: 'https://en.wikipedia.org/wiki/Getseman%C3%AD,_Cartagena' } ] } },
|
||||
{ id: 'S3', name: 'Lunch — La Casona de San Diego', kind: 'meal', mealKind: 'lunch',
|
||||
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
|
||||
at: [10.3940, -75.5545], dur: 75, durConf: 3,
|
||||
at: [10.41950, -75.54600], dur: 75, durConf: 3,
|
||||
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
|
||||
price: { amount: 18, cur: '$', conf: 2, src: 'tripadvisor.com' },
|
||||
img: ['🍲', 'La Casona de San Diego', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'],
|
||||
|
|
@ -589,7 +589,7 @@ const COLOMBIA = {
|
|||
summary: 'Caribbean home cooking in a whitewashed casona — sancocho de pescado, mango con arepa. Cash only; the line moves fast at 12:30.',
|
||||
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
|
||||
{ id: 'S4', name: 'Mercado de Getsemaní', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
at: [10.3945, -75.5530], dur: 45, durConf: 3,
|
||||
at: [10.41880, -75.54480], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 2, src: null },
|
||||
price: null,
|
||||
img: ['🥭', 'Mercado de Getsemaní', 'linear-gradient(135deg,#8f6b3c,#5c441f)'],
|
||||
|
|
@ -609,12 +609,12 @@ const COLOMBIA = {
|
|||
},
|
||||
{ id: 'D2', date: '2026-09-22', label: 'Day 2 · Tue 22',
|
||||
startMin: 8 * 60 + 30, wakingHours: 12,
|
||||
base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] },
|
||||
base: { name: 'Hotel Casa San Antonio', at: [10.42050, -75.54650] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S6', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [10.3952, -75.5522], dur: 30, durConf: 3,
|
||||
at: [10.42050, -75.54650], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S7', name: 'Casa de la Moneda', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://en.wikipedia.org/wiki/Casa_de_la_Moneda_(Cartagena)',
|
||||
|
|
@ -636,7 +636,7 @@ const COLOMBIA = {
|
|||
links: [ { label: 'Palacio de la Inquisición', href: 'https://en.wikipedia.org/wiki/Palacio_de_la_Inquisici%C3%B3n_(Cartagena)' } ] } },
|
||||
{ id: 'S9', name: 'San Pedro Claver', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://en.wikipedia.org/wiki/San_Pedro_Claver_Church',
|
||||
at: [10.4232, -75.5528], dur: 45, durConf: 3,
|
||||
at: [10.42177, -75.55121], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 2, src: null },
|
||||
price: null,
|
||||
img: ['🕊️', 'San Pedro Claver', 'linear-gradient(135deg,#7a7a8f,#4f4f61)'],
|
||||
|
|
@ -654,7 +654,7 @@ const COLOMBIA = {
|
|||
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
|
||||
{ id: 'S11', name: 'Cerro La Popa — sunset', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://en.wikipedia.org/wiki/Cerro_de_La_Popa',
|
||||
at: [10.4257, -75.5523], dur: 75, durConf: 3,
|
||||
at: [10.42000, -75.52560], dur: 75, durConf: 3,
|
||||
suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' },
|
||||
price: { amount: 10, cur: '$', conf: 2, src: 'cartagenatourism.com' },
|
||||
img: ['🌅', 'Cerro La Popa', 'linear-gradient(135deg,#c95c3c,#8a3a1f)'],
|
||||
|
|
@ -665,16 +665,16 @@ const COLOMBIA = {
|
|||
},
|
||||
{ id: 'D3', date: '2026-09-23', label: 'Day 3 · Wed 23',
|
||||
startMin: 8 * 60, wakingHours: 12,
|
||||
base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] },
|
||||
base: { name: 'Hotel Casa San Antonio', at: [10.42050, -75.54650] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S12', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [10.3952, -75.5522], dur: 30, durConf: 3,
|
||||
at: [10.42050, -75.54650], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S13', name: 'Castillo San Felipe', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://www.castillosanfelipe.gov.co',
|
||||
at: [10.3930, -75.5565], dur: 90, durConf: 3,
|
||||
at: [10.42271, -75.53907], dur: 90, durConf: 3,
|
||||
suggested: { value: 120, conf: 2, src: 'castillosanfelipe.gov.co' },
|
||||
price: { amount: 15, cur: '$', conf: 2, src: 'castillosanfelipe.gov.co' },
|
||||
img: ['🏰', 'Castillo San Felipe', 'linear-gradient(135deg,#8a8f3c,#565b24)'],
|
||||
|
|
@ -686,7 +686,7 @@ const COLOMBIA = {
|
|||
] } },
|
||||
{ id: 'S14', name: 'Castillo San Diego', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://en.wikipedia.org/wiki/Castillo_San_Diego',
|
||||
at: [10.4247, -75.5512], dur: 75, durConf: 3,
|
||||
at: [10.42720, -75.55370], dur: 75, durConf: 3,
|
||||
suggested: { value: 75, conf: 2, src: 'cartagenatourism.com' },
|
||||
price: { amount: 8, cur: '$', conf: 2, src: 'cartagenatourism.com' },
|
||||
img: ['⚓', 'Castillo San Diego', 'linear-gradient(135deg,#3c8f8a,#1f5c59)'],
|
||||
|
|
@ -714,16 +714,16 @@ const COLOMBIA = {
|
|||
},
|
||||
{ id: 'D4', date: '2026-09-24', label: 'Day 4 · Thu 24',
|
||||
startMin: 9 * 60, wakingHours: 12,
|
||||
base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] },
|
||||
base: { name: 'Hotel Casa San Antonio', at: [10.42050, -75.54650] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S17', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [10.3952, -75.5522], dur: 30, durConf: 3,
|
||||
at: [10.42050, -75.54650], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S18', name: 'Punta Canoa', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://en.wikipedia.org/wiki/Punta_Canoa',
|
||||
at: [10.4185, -75.5255], dur: 90, durConf: 3,
|
||||
at: [10.44600, -75.53100], dur: 90, durConf: 3,
|
||||
suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' },
|
||||
price: null,
|
||||
img: ['🏖️', 'Punta Canoa', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'],
|
||||
|
|
@ -732,7 +732,7 @@ const COLOMBIA = {
|
|||
links: [ { label: 'Punta Canoa', href: 'https://en.wikipedia.org/wiki/Punta_Canoa' } ] } },
|
||||
{ id: 'S19', name: 'Lunch — beachside at Punta Canoa', kind: 'meal', mealKind: 'lunch',
|
||||
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
|
||||
at: [10.4190, -75.5245], dur: 75, durConf: 3,
|
||||
at: [10.44550, -75.53150], dur: 75, durConf: 3,
|
||||
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
|
||||
price: { amount: 15, cur: '$', conf: 2, src: 'tripadvisor.com' },
|
||||
img: ['🍤', 'Punta Canoa beach', 'linear-gradient(135deg,#c9803c,#8a541f)'],
|
||||
|
|
@ -751,12 +751,12 @@ const COLOMBIA = {
|
|||
},
|
||||
{ id: 'D5', date: '2026-09-25', label: 'Day 5 · Fri 25',
|
||||
startMin: 7 * 60, wakingHours: 11,
|
||||
base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] },
|
||||
base: { name: 'Hotel Muelle de Bolívar', at: [11.23950, -74.21550] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S21', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [10.3952, -75.5522], dur: 45, durConf: 3,
|
||||
at: [10.42050, -75.54650], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S22', name: 'Vuelo CTG → SMR', kind: 'transit', slot: 'transit', state: 'planned',
|
||||
at: [10.4425, -75.5131], dur: 45, durConf: 4,
|
||||
|
|
@ -789,12 +789,12 @@ const COLOMBIA = {
|
|||
},
|
||||
{ id: 'D6', date: '2026-09-26', label: 'Day 6 · Sat 26',
|
||||
startMin: 8 * 60 + 30, wakingHours: 11,
|
||||
base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] },
|
||||
base: { name: 'Hotel Muelle de Bolívar', at: [11.23950, -74.21550] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S25', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [11.2390, -74.2130], dur: 30, durConf: 3,
|
||||
at: [11.23950, -74.21550], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S26', name: 'Casa de Raquel', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://en.wikipedia.org/wiki/Casa_de_Raquel',
|
||||
|
|
@ -807,7 +807,7 @@ const COLOMBIA = {
|
|||
links: [ { label: 'Casa de Raquel', href: 'https://en.wikipedia.org/wiki/Casa_de_Raquel' } ] } },
|
||||
{ id: 'S27', name: 'Lunch — La Terraza del Muelle', kind: 'meal', mealKind: 'lunch',
|
||||
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
|
||||
at: [11.2375, -74.2140], dur: 75, durConf: 3,
|
||||
at: [11.23750, -74.21700], dur: 75, durConf: 3,
|
||||
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
|
||||
price: { amount: 20, cur: '$', conf: 2, src: 'tripadvisor.com' },
|
||||
img: ['🐟', 'La Terraza del Muelle', 'linear-gradient(135deg,#3c6b8f,#1f445c)'],
|
||||
|
|
@ -815,7 +815,7 @@ const COLOMBIA = {
|
|||
summary: 'Grilled fish on the terrace over the working harbour — the boats come in around noon and the menu follows the catch. The shrimp ceviche is the safe first order.',
|
||||
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
|
||||
{ id: 'S28', name: 'El Muelle & fish market', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
at: [11.2360, -74.2150], dur: 45, durConf: 3,
|
||||
at: [11.23600, -74.21900], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 2, src: null },
|
||||
price: null,
|
||||
img: ['⚓', 'El Muelle de Santa Marta', 'linear-gradient(135deg,#5c6b8f,#3a445c)'],
|
||||
|
|
@ -823,7 +823,7 @@ const COLOMBIA = {
|
|||
summary: 'The 19th-century stone pier and the market behind it — ice, line-caught fish, and the same crews who have run these boats for a century. The best 45 minutes in the city if you like to watch work happen.',
|
||||
links: [ { label: 'El Muelle', href: 'https://en.wikipedia.org/wiki/El_Muelle_(Santa_Marta)' } ] } },
|
||||
{ id: 'S29', name: 'Parque de los Novios', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
at: [11.2395, -74.2085], dur: 45, durConf: 3,
|
||||
at: [11.24220, -74.21330], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 2, src: 'santamarta.gov.co' },
|
||||
price: null,
|
||||
img: ['💑', 'Parque de los Novios', 'linear-gradient(135deg,#6b8f4f,#445c2c)'],
|
||||
|
|
@ -834,15 +834,15 @@ const COLOMBIA = {
|
|||
},
|
||||
{ id: 'D7', date: '2026-09-27', label: 'Day 7 · Sun 27',
|
||||
startMin: 7 * 60 + 30, wakingHours: 12,
|
||||
base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] },
|
||||
base: { name: 'Hotel Muelle de Bolívar', at: [11.23950, -74.21550] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S30', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [11.2390, -74.2130], dur: 30, durConf: 3,
|
||||
at: [11.23950, -74.21550], dur: 30, durConf: 3,
|
||||
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S31', name: 'Bus → Tayrona (El Croado)', kind: 'transit', slot: 'transit', state: 'planned',
|
||||
at: [11.1975, -74.1760], dur: 40, durConf: 3,
|
||||
at: [11.19750, -74.22900], dur: 40, durConf: 3,
|
||||
transit: { name: 'Bus Centro → El Croado', ref: 'TAY-01',
|
||||
from: 'Santa Marta Centro', to: 'Parque Tayrona, El Croado',
|
||||
depart: '08:30', arrive: '09:10', arriveBy: '08:30',
|
||||
|
|
@ -852,7 +852,7 @@ const COLOMBIA = {
|
|||
img: ['🚌', 'Bus to Tayrona', 'linear-gradient(135deg,#8f6b3c,#5c441f)'] },
|
||||
{ id: 'S32', name: 'Playa Cangrejos', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://parquetayrona.gov.co',
|
||||
at: [11.1765, -74.1690], dur: 60, durConf: 3,
|
||||
at: [11.17650, -74.23050], dur: 60, durConf: 3,
|
||||
suggested: { value: 60, conf: 2, src: 'parquetayrona.gov.co' },
|
||||
price: { amount: 15, cur: '$', conf: 3, src: 'parquetayrona.gov.co' },
|
||||
img: ['🌴', 'Playa Cangrejos', 'linear-gradient(135deg,#3cb5c9,#1f5c44)'],
|
||||
|
|
@ -861,7 +861,7 @@ const COLOMBIA = {
|
|||
links: [ { label: 'Parque Tayrona', href: 'https://parquetayrona.gov.co' } ] } },
|
||||
{ id: 'S33', name: 'Cabo San Juan', kind: 'visit', slot: 'visit', state: 'planned',
|
||||
url: 'https://parquetayrona.gov.co',
|
||||
at: [11.1390, -74.1455], dur: 120, durConf: 3,
|
||||
at: [11.13900, -74.22850], dur: 120, durConf: 3,
|
||||
suggested: { value: 120, conf: 2, src: 'parquetayrona.gov.co' },
|
||||
price: null,
|
||||
img: ['🏝️', 'Cabo San Juan', 'linear-gradient(135deg,#c95c8a,#8a1f5c)'],
|
||||
|
|
@ -872,12 +872,12 @@ const COLOMBIA = {
|
|||
},
|
||||
{ id: 'D8', date: '2026-09-28', label: 'Day 8 · Mon 28', isDeparture: true,
|
||||
startMin: 9 * 60, wakingHours: 6,
|
||||
base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] },
|
||||
base: { name: 'Hotel Muelle de Bolívar', at: [11.23950, -74.21550] },
|
||||
legs: [],
|
||||
stops: [
|
||||
{ id: 'S34', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast',
|
||||
slot: 'breakfast', state: 'planned',
|
||||
at: [11.2390, -74.2130], dur: 45, durConf: 3,
|
||||
at: [11.23950, -74.21550], dur: 45, durConf: 3,
|
||||
suggested: { value: 45, conf: 2, src: 'tripadvisor.com' } },
|
||||
{ id: 'S35', name: 'Vuelo SMR → BOG', kind: 'transit', slot: 'transit', state: 'planned',
|
||||
at: [11.1165, -74.2330], dur: 45, durConf: 4,
|
||||
|
|
@ -893,15 +893,15 @@ const COLOMBIA = {
|
|||
],
|
||||
candidates: {
|
||||
hotel: [
|
||||
{ id: 'H1', name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522], price: 120, dur: null,
|
||||
{ id: 'H1', name: 'Hotel Casa San Antonio', at: [10.42050, -75.54650], price: 120, dur: null,
|
||||
tags: ['Getsemaní', 'colonial casona', 'roof terrace'], pitch: 'Where you are now — a whitewashed casona in Getsemaní with a terrace over the murals.', url: 'https://www.tripadvisor.com' },
|
||||
{ id: 'H2', name: 'Hotel Casa 1800', at: [10.4606, -75.5547], price: 260, dur: null,
|
||||
{ id: 'H2', name: 'Hotel Casa 1800', at: [10.42380, -75.55380], price: 260, dur: null,
|
||||
tags: ['old town', 'design', 'pool'], pitch: 'In the old town instead — the 19th-century palace turned design hotel; pricier, but you skip the walk entirely.', url: 'https://www.casa1800.com' },
|
||||
{ id: 'H3', name: 'Villa El Banderito', at: [10.4340, -75.5365], price: 140, dur: null,
|
||||
tags: ['Bocagrande', 'beachfront', 'quiet'], pitch: 'On the Bocagrande sand — wake up at the water; a 15-min tuktuk from Getsemaní.', url: 'https://www.tripadvisor.com' }
|
||||
],
|
||||
lunch: [
|
||||
{ id: 'C1', name: 'La Casona de San Diego', at: [10.3940, -75.5545], price: 18, dur: 75,
|
||||
{ id: 'C1', name: 'La Casona de San Diego', at: [10.41950, -75.54600], price: 18, dur: 75,
|
||||
tags: ['caribbean', 'getsemaní', 'cash only'], pitch: 'Sancocho and mango con arepa in a whitewashed casona — the neighbourhood’s own lunch.',
|
||||
url: 'https://www.tripadvisor.com', img: ['🍲', 'La Casona', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'] },
|
||||
{ id: 'C2', name: 'La Loma de San Diego', at: [10.4230, -75.5525], price: 25, dur: 75,
|
||||
|
|
@ -910,7 +910,7 @@ const COLOMBIA = {
|
|||
{ id: 'C3', name: 'Club El Boat', at: [10.4355, -75.5375], price: 20, dur: 90,
|
||||
tags: ['bocagrande', 'seafood', 'sunset'], pitch: 'Langosta on the malecón — the sunset is the side order.',
|
||||
url: 'https://www.tripadvisor.com', img: ['🦐', 'El Boat', 'linear-gradient(135deg,#3c6b8f,#1f445c)'] },
|
||||
{ id: 'C4', name: 'La Terraza del Muelle', at: [11.2375, -74.2140], price: 20, dur: 75,
|
||||
{ id: 'C4', name: 'La Terraza del Muelle', at: [11.23750, -74.21700], price: 20, dur: 75,
|
||||
tags: ['santa marta', 'harbour', 'catch of the day'], pitch: 'Grilled fish over the working pier — the boats set the menu.',
|
||||
url: 'https://www.tripadvisor.com', img: ['🐟', 'Terraza del Muelle', 'linear-gradient(135deg,#3c6b8f,#1f445c)'] }
|
||||
],
|
||||
|
|
@ -918,13 +918,13 @@ const COLOMBIA = {
|
|||
{ id: 'V1', name: 'Playa Bocagrande', at: [10.4330, -75.5340], price: 0, dur: 90,
|
||||
tags: ['beach', 'free', 'swim'], pitch: 'The city beach — a proper swim and a hammock, ten minutes from the old town by tuktuk.',
|
||||
url: 'https://en.wikipedia.org/wiki/Bocagrande', img: ['🏖️', 'Bocagrande', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'] },
|
||||
{ id: 'V2', name: 'Plaza Peñol', at: [10.4632, -75.5570], price: 0, dur: 30,
|
||||
{ id: 'V2', name: 'Plaza Peñol', at: [10.42480, -75.55420], price: 0, dur: 30,
|
||||
tags: ['free', 'old town', 'shades'], pitch: 'The old town’s leafy pause — the black umbrellas and the shade are the whole point.',
|
||||
url: 'https://en.wikipedia.org/wiki/Cartagena', img: ['☂️', 'Plaza Peñol', 'linear-gradient(135deg,#4f7a4f,#2c4f2c)'] },
|
||||
{ id: 'V3', name: 'Iglesia de la Popa', at: [10.4253, -75.5530], price: 10, dur: 30,
|
||||
{ id: 'V3', name: 'Iglesia de la Popa', at: [10.41915, -75.52557], price: 10, dur: 30,
|
||||
tags: ['view', 'hilltop', 'sunset'], pitch: 'The church itself in 15 min — the terrace does the rest.',
|
||||
url: 'https://en.wikipedia.org/wiki/Cerro_de_La_Popa', img: ['⛪', 'Iglesia de la Popa', 'linear-gradient(135deg,#7a7a8f,#4f4f61)'] },
|
||||
{ id: 'V4', name: 'Playa Cocal, Tayrona', at: [11.1650, -74.1620], price: 0, dur: 60,
|
||||
{ id: 'V4', name: 'Playa Cocal, Tayrona', at: [11.16500, -74.22650], price: 0, dur: 60,
|
||||
tags: ['tayrona', 'beach', 'jungle'], pitch: 'The quieter of the two main Tayrona beaches — the jungle wall is closer to the sand here.',
|
||||
url: 'https://parquetayrona.gov.co', img: ['🌴', 'Playa Cocal', 'linear-gradient(135deg,#4f9e63,#2c6b40)'] },
|
||||
{ id: 'V5', name: 'Cerro de La Popa (Santa Marta)', at: [11.2460, -74.2020], price: 2, dur: 60,
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
<input id="doc-file" type="file" accept=".pdf,.txt,.ics,.html,.htm,.eml,.csv,.json" hidden />
|
||||
<button class="btn small" id="doc-attach" title="Attach a booking or confirmation (PDF, HTML email, .eml, text) — flight bookings reconfigure the trip, and everything stays attached for reference">📎</button>
|
||||
<input id="chat-input" type="text" placeholder="Ask, direct — or drop a booking…" />
|
||||
<button class="btn small think-toggle on" id="think-toggle" title="LLM thinking: on = deliberate (slower, better planning) · off = quick answers (faster)">💭 on</button>
|
||||
<button class="btn primary small" id="chat-send">→</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -29,10 +29,13 @@ const ROUTERS = {
|
|||
};
|
||||
// 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.
|
||||
// sends messages. The model must be loaded under this exact alias —
|
||||
// /llm-status probes it, and /llm-chat 400s on a mismatch (check the
|
||||
// llama.cpp manager's model list if chat starts saying "model not found").
|
||||
// Qwen3.8-27B-dual: --parallel 2, --ctx-size 262144, vision (mmproj).
|
||||
const LLM = {
|
||||
base: process.env.LLM_BASE || 'http://192.168.3.7:1234',
|
||||
model: process.env.LLM_MODEL || 'Qwen3.8-27B-UD3-Q5-dual',
|
||||
model: process.env.LLM_MODEL || 'Qwen3.8-27B-dual',
|
||||
ctx: 131072,
|
||||
maxTokens: +(process.env.LLM_MAX_TOKENS || 8192), // thinking + answer share this budget
|
||||
};
|
||||
|
|
@ -365,15 +368,29 @@ http.createServer((req, res) => {
|
|||
temperature: in0.temperature ?? 0.7,
|
||||
stream: false,
|
||||
};
|
||||
// thinking control: the client sends reasoning_effort 'none' for quick
|
||||
// turns (the app's thinking toggle). Pass it through — this build of
|
||||
// llama.cpp honours it (verified: 0 reasoning tokens when set).
|
||||
if (in0.reasoning_effort) payload.reasoning_effort = in0.reasoning_effort;
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
// latency accounting for the slow-turn diagnosis: prompt size (chars +
|
||||
// image payloads) vs wall time, per pass of the client-side tool loop
|
||||
const promptChars = JSON.stringify(payload.messages).length;
|
||||
const imgs = payload.messages.reduce((n, m) => n + (Array.isArray(m.content) ? m.content.filter(c => c.type === 'image_url').length : 0), 0);
|
||||
const t0 = Date.now();
|
||||
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();
|
||||
let outTok = null, inTok = null;
|
||||
try { const j = JSON.parse(t); inTok = j.usage?.prompt_tokens; outTok = j.usage?.completion_tokens; }
|
||||
catch {}
|
||||
console.log(`[llm] ${((Date.now() - t0) / 1000).toFixed(1)}s in=${inTok ?? '?'}tok out=${outTok ?? '?'}tok prompt~${Math.round(promptChars / 4)}tok imgs=${imgs} ok=${r.ok}`);
|
||||
res.writeHead(r.ok ? 200 : 502);
|
||||
res.end(t);
|
||||
}).catch(() => {
|
||||
}).catch((e) => {
|
||||
console.log(`[llm] ${((Date.now() - t0) / 1000).toFixed(1)}s FAILED: ${e.message}`);
|
||||
res.writeHead(502);
|
||||
res.end(JSON.stringify({ error: 'LLM server unreachable at ' + LLM.base }));
|
||||
});
|
||||
|
|
@ -570,6 +587,27 @@ http.createServer((req, res) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// plan state — the single source of truth lives HERE (plan.json), not in
|
||||
// each browser's localStorage. The client loads it on enterTrip, pushes
|
||||
// it on every commit (debounced), and offers it as a downloadable export.
|
||||
if (seg[1] === 'plan' && (req.method === 'GET' || req.method === 'PUT')) {
|
||||
const f = path.join(dir, 'plan.json');
|
||||
if (req.method === 'GET') {
|
||||
try { return json({ ok: true, plan: JSON.parse(fs.readFileSync(f, 'utf8')), savedAt: fs.statSync(f).mtimeMs }); }
|
||||
catch { return json({ ok: false, error: 'no saved plan yet' }); }
|
||||
}
|
||||
readBody(req, (err, in0) => {
|
||||
if (err) return json({ ok: false, error: 'bad plan payload' });
|
||||
try {
|
||||
fs.writeFileSync(f + '.tmp', JSON.stringify(in0));
|
||||
fs.renameSync(f + '.tmp', f);
|
||||
console.log(`[store] ${tripId}/plan.json ${(fs.statSync(f).size / 1024).toFixed(0)}KB v${in0.version ?? '?'} ${in0.label || ''}`);
|
||||
json({ ok: true });
|
||||
} catch (e) { json({ ok: false, error: 'could not save plan: ' + e.message }); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404); return res.end('no such store route');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ button { font: inherit; }
|
|||
#chat { background: var(--card); border-left: 1px solid var(--line); display: flex; flex-direction: column; }
|
||||
#chat-body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
#chat-input-row { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--line); align-items: center; }
|
||||
/* LLM thinking toggle (next to send): on = accent/active, off = muted */
|
||||
.think-toggle { background: #eef0f4; color: var(--ink); white-space: nowrap; flex: 0 0 auto; }
|
||||
.think-toggle.on { background: var(--accent); color: #fff; }
|
||||
.think-toggle:not(.on) { opacity: .7; }
|
||||
#doc-attach { cursor: pointer; line-height: 1; }
|
||||
#doc-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 12px 0; max-height: 54px; overflow-y: auto; }
|
||||
#doc-chips.hidden { display: none; }
|
||||
|
|
|
|||
|
|
@ -353,8 +353,11 @@ func filters(q map[string][]string) (string, []any) {
|
|||
if n := first(q, "name"); n != "" {
|
||||
conds = append(conds, "poi.name ILIKE "+param("%"+n+"%"))
|
||||
}
|
||||
// "WHERE 1=1" (not "") — the callers unconditionally append "AND …" to
|
||||
// the where clause, so an empty string produced a SQL syntax error on
|
||||
// /near, /nearest and /corridor with no kind/name/extract filters.
|
||||
if len(conds) == 0 {
|
||||
return "", args
|
||||
return " WHERE 1=1", args
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user