diff --git a/mock/app.js b/mock/app.js index 769ebc3..05c9021 100644 --- a/mock/app.js +++ b/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); } - messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) }); + // 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); - if (legTokens[key] !== token) return; - if (res) { d.retRouted = res; if (d === day) renderAll(); } + 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) { 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', `${MODE_ICO[travelMode] || '•'}back to ${dayBase() ? dayBase().name : 'hotel'} `); - ret.append(tchip(returnDur(), multi ? 1 : 3)); + const leg = getReturnLeg(day); + const ret = el('div', 'leg-row', `${MODE_ICO[(leg && leg.mode) || travelMode] || '•'}back to ${dayBase() ? dayBase().name : 'hotel'} `); + 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 ⚖ 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 ${fmt(layout())}.`); - } 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 alternatives 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//plan.json, via /trip-store//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//) — 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 = `🧳 ${M.trip.title} ▾`; @@ -2500,9 +2827,24 @@ function renderTripMenu() { more.innerHTML = `+ New trip…start from a booking on the landing screen`; more.onclick = () => location.href = location.pathname; menu.append(more); + const ex = el('button', 'trip-item'); + ex.innerHTML = `⬇ Export plan (JSON)download the server-side plan file`; + 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 ${M.trip.title} — you’re at v${version} (“${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 ${M.trip.title} — you’re at v${version} (“${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(); diff --git a/mock/data.js b/mock/data.js index f46e746..83a2be3 100644 --- a/mock/data.js +++ b/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, diff --git a/mock/index.html b/mock/index.html index bf9c618..7389b3e 100644 --- a/mock/index.html +++ b/mock/index.html @@ -122,6 +122,7 @@ + diff --git a/mock/server.js b/mock/server.js index 69e9b06..f950a98 100644 --- a/mock/server.js +++ b/mock/server.js @@ -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'); } diff --git a/mock/styles.css b/mock/styles.css index e9f2e7d..3985ba8 100644 --- a/mock/styles.css +++ b/mock/styles.css @@ -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; } diff --git a/spatial/main.go b/spatial/main.go index 2c07782..4f136fc 100644 --- a/spatial/main.go +++ b/spatial/main.go @@ -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 }