diff --git a/mock/app.js b/mock/app.js
index 1dbfffb..38a4a7a 100644
--- a/mock/app.js
+++ b/mock/app.js
@@ -60,30 +60,250 @@ function llmContext() {
layout(dd);
const stops = dd.stops.filter(s => s.state === 'planned').map(
s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
- L.push(`${dd.label} [${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
+ L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
}
return L.join('\n');
}
-function askLLM(v) {
- const t = typing();
+// ---------------- LLM agent: tools + calling loop ----------------
+// The model is the orchestrator: it reads the plan, calls deterministic
+// tools (search / route / review), and proposes small fix-ops (add / remove /
+// move / re-time). The tools decide — they validate, compute, and commit, so
+// every edit is undo-able. This llama.cpp build does not honor the native
+// OpenAI `tools` field, so tool calls use a text protocol: the model emits
+// {"name":...,"args":{...}}, we execute it, and feed the JSON
+// result back as the next turn; the loop ends when the model stops calling.
+const LLM_TOOL_MAX = 10; // max tool-call turns per user message
+
+// Live-state lookups (the source of truth is `days`, not the original M.days)
+function liveStopById(id) {
+ for (const d of Object.values(days)) { const s = d.stops.find(x => x.id === id); if (s) return s; }
+ return null;
+}
+function candById(id) {
+ for (const cat of Object.keys(M.candidates)) {
+ const c = M.candidates[cat].find(x => x.id === id);
+ if (c) return { ...c, cat };
+ }
+ return null;
+}
+// Resolve a model-given place ref — "hotel", a stop/candidate id, or a name
+// (exact then substring) — to {name, at, id}
+function resolvePlace(ref) {
+ const q = String(ref || '').trim(); const ql = q.toLowerCase();
+ if (!q) return null;
+ if (ql === 'hotel' || ql === 'base' || ql === 'the hotel' || ql === 'my hotel') {
+ const b = dayBase(); return { name: b.name, at: b.at, id: b.id || 'hotel' };
+ }
+ const s = liveStopById(q); if (s) return { name: s.name, at: s.at, id: s.id };
+ const c = candById(q); if (c) return { name: c.name, at: c.at, id: c.id };
+ const all = [];
+ Object.values(days).forEach(d => d.stops.forEach(x => all.push(x)));
+ Object.values(M.candidates).forEach(pool => pool.forEach(x => all.push(x)));
+ const hit = all.find(x => x.name.toLowerCase() === ql) || all.find(x => x.name.toLowerCase().includes(ql));
+ return hit ? { name: hit.name, at: hit.at, id: hit.id } : null;
+}
+// Resolve a live stop by id or name (the model often names stops, not ids)
+function resolveStop(ref) {
+ const s = liveStopById(ref); if (s) return s;
+ const q = String(ref || '').trim().toLowerCase();
+ if (!q) return null;
+ for (const d of Object.values(days)) {
+ const hit = d.stops.find(x => x.name.toLowerCase() === q) || d.stops.find(x => x.name.toLowerCase().includes(q));
+ if (hit) return hit;
+ }
+ return null;
+}
+
+const TOOLS = {
+ // ---- queries (read-only) ----
+ itinerary_summary: () => ({ plan: llmContext() }),
+
+ search_places: (a) => {
+ const cat = String(a.category || '').toLowerCase();
+ if (!M.candidates[cat]) return { error: `unknown category "${a.category}"; use ${Object.keys(M.candidates).join(', ')}` };
+ let region = null;
+ if (a.region) {
+ region = REGION_MAP[String(a.region).toLowerCase()];
+ if (!region) return { error: `unknown region "${a.region}"; use ${Object.keys(REGION_MAP).join(', ')} or omit` };
+ }
+ const results = poolFor(cat, region).map(c => ({
+ id: c.id, name: c.name, price_eur: c.price, dur_min: c.dur, tags: c.tags,
+ pitch: c.pitch, walk_from_hotel_min: hotelWalkMin(c.at),
+ }));
+ return { category: cat, region: a.region || null, count: results.length, results };
+ },
+
+ place_facts: (a) => {
+ const p = resolvePlace(a.id || a.name);
+ if (!p) return { error: `no place matching "${a.id || a.name}"` };
+ const c = candById(p.id) || liveStopById(p.id);
+ return { name: p.name, price_eur: c?.price, dur_min: c?.dur, tags: c?.tags, pitch: c?.pitch,
+ walk_from_hotel_min: hotelWalkMin(p.at), url: c?.url || null, summary: c?.detail?.summary || null };
+ },
+
+ route_between: async (a) => {
+ const A = resolvePlace(a.from), B = resolvePlace(a.to);
+ if (!A) return { error: `cannot resolve "from" = "${a.from}"` };
+ 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');
+ 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 };
+ },
+
+ review_plan: (a) => {
+ const ids = a.dayId ? [a.dayId] : Object.keys(days);
+ const issues = [];
+ for (const id of ids) {
+ const d = days[id]; if (!d) { issues.push(`${id}: not a day`); continue; }
+ layout(d);
+ const prim = d.stops.filter(s => s.state === 'planned');
+ const planned = prim.reduce((t, s) => t + s.dur, 0) + d.legs.reduce((t, g) => t + (g?.dur || 0), 0);
+ const endMin = d.startMin + planned;
+ const capMin = d.startMin + (d.wakingHours || 11) * 60;
+ if (endMin > capMin) issues.push(`${d.label}: planned time ends ~${fmt(endMin)} but the waking window closes ~${fmt(capMin)} — ~${Math.round(endMin - capMin)} min over`);
+ prim.forEach(s => {
+ if (s.slot === 'lunch' && (s.start < 11 * 60 || s.start > 15 * 60)) issues.push(`${d.label}: lunch at ${s.name} starts ${fmt(s.start)} — outside a normal 11:00–15:00 window`);
+ if (s.slot === 'dinner' && (s.start < 17 * 60 || s.start > 21 * 60)) issues.push(`${d.label}: dinner at ${s.name} starts ${fmt(s.start)} — outside a normal 17:00–21:00 window`);
+ });
+ const seen = {};
+ prim.forEach(s => { const k = s.name.toLowerCase(); if (seen[k] && !s.name.includes('—')) issues.push(`${d.label}: "${s.name}" appears more than once in the plan`); seen[k] = (seen[k] || 0) + 1; });
+ if (!prim.length) issues.push(`${d.label}: has no planned stops`);
+ }
+ return { issues: issues.length ? issues : ['no problems found'] };
+ },
+
+ // ---- fix-ops (mutations; each commits → undo-able) ----
+ add_stop: (a) => {
+ const SLOTS = ['lunch', 'dinner', 'visit'];
+ const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"; days are ${Object.keys(days).join(', ')}` };
+ const c = candById(a.candidateId); if (!c) return { error: `no candidate "${a.candidateId}" — call search_places first` };
+ const slot = SLOTS.includes(String(a.slot || c.cat).toLowerCase()) ? String(a.slot || c.cat).toLowerCase() : c.cat;
+ if (slot === 'hotel') return { error: 'hotels are stays, not day stops — point the user to the hotel drawer' };
+ const stop = makeStop(c, slot, a.state && ['planned', 'maybe', 'alt'].includes(a.state) ? a.state : 'planned');
+ const inc = d.stops.find(x => x.slot === slot && x.state === 'planned' && x.id !== stop.id);
+ if (inc && stop.state === 'planned') inc.state = 'alt'; // one slot, one plan
+ d.stops.push(stop);
+ rebuildLegs(d); commit(`[llm] ${stop.name} → ${stateLabel(stop.state)} on ${d.label}`); renderAll([stop.id]);
+ return { ok: true, action: 'add_stop', stop: stop.name, slot, state: stop.state, day: d.label, demoted: inc ? inc.name : null };
+ },
+
+ remove_stop: (a) => {
+ const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
+ const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
+ d.stops.splice(d.stops.indexOf(stop), 1);
+ rebuildLegs(d); commit(`[llm] removed ${stop.name} from ${d.label}`); renderAll();
+ return { ok: true, action: 'remove_stop', removed: stop.name, day: d.label };
+ },
+
+ move_stop: (a) => {
+ const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
+ const to = days[a.toDayId]; if (!to) return { error: `unknown day "${a.toDayId}"` };
+ const fromId = Object.keys(days).find(k => days[k].stops.includes(stop));
+ days[fromId].stops.splice(days[fromId].stops.indexOf(stop), 1);
+ to.stops.push(stop);
+ rebuildLegs(days[fromId]); rebuildLegs(to);
+ commit(`[llm] moved ${stop.name} → ${to.label}`); renderAll();
+ return { ok: true, action: 'move_stop', stop: stop.name, from: days[fromId].label, to: to.label };
+ },
+
+ set_duration: (a) => {
+ const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
+ const mins = Math.round(+a.minutes || 0); if (!mins || mins < 5) return { error: 'minutes must be a number ≥ 5' };
+ const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
+ stop.dur = mins; stop.suggested = { value: mins, conf: 4, src: 'assistant' };
+ rebuildLegs(d); layout(d); commit(`[llm] ${stop.name} → ${mins} min`); renderAll([stop.id]);
+ return { ok: true, action: 'set_duration', stop: stop.name, minutes: mins, day: d.label, day_ends: fmt(layout(d)) };
+ },
+
+ set_start: (a) => {
+ const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` };
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' };
+ const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' };
+ d.startMin = h * 60 + mn;
+ rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll();
+ return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) };
+ },
+};
+
+function llmSystemPrompt() {
+ const cats = Object.keys(M.candidates);
+ const regions = Object.keys(REGION_MAP).map(k => REGION_MAP[k].label);
+ return (
+ 'You are the planning assistant inside a trip-planning app. The user is refining a trip and you are its orchestrator. Never invent times, prices, or places — read them from tools. Deterministic tools compute routes and validate the plan; you decide which to call and turn the results into a short, warm answer.\n\n' +
+ 'You may call TOOLS. To call one, reply with ONLY this exact format — a single fenced json block, nothing else on that turn:\n' +
+ '\n{"name":"TOOL_NAME","args":{...}}\n\n' +
+ 'The tool result is then returned to you. From there you either call another tool the same way, or give your final natural-language answer.\n\n' +
+ 'TOOLS (name — args — what it does):\n' +
+ ' itinerary_summary() — the current plan: bookings, stays, and each day\'s scheduled stops with times.\n' +
+ ' search_places(category, region?) — candidate places. category ∈ {' + cats.join(', ') + '} ; region ∈ {' + regions.join(', ') + '} or omit. Returns id, name, price_eur, dur_min, tags, pitch, walk_from_hotel_min.\n' +
+ ' place_facts(id) — full facts for one place (id or name): price, duration, tags, pitch, walk from hotel, summary.\n' +
+ ' route_between(from, to) — real walking minutes + km between two places ("hotel" or a place id/name).\n' +
+ ' review_plan(dayId?) — deterministic checks: day overrun vs the waking window, meals at implausible hours, duplicates. dayId optional.\n' +
+ ' add_stop(dayId, slot, candidateId, state?) — put a candidate on a day. slot ∈ lunch,dinner,visit. If the slot is taken the current stop is demoted to a backup (a swap). state ∈ planned,idea,backup (default planned). candidateId comes from search_places.\n' +
+ ' remove_stop(stopId) — remove a stop from its day. stopId is a stop id or its name.\n' +
+ ' 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\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.\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\n' +
+ 'Current plan:\n' + llmContext());
+}
+
+// Parse a {...} block from the model output, if present.
+function parseToolBlock(text) {
+ const m = /\s*([\s\S]*?)\s*<\/tool>/.exec(text);
+ if (!m) return null;
+ try { return JSON.parse(m[1]); } catch { return null; }
+}
+// A subtle activity line so the user sees the model working its tools.
+function toolLine(name, args) {
+ const m = el('div', 'msg tool', '⚙ ' + esc(name) + (args && Object.keys(args).length ? ' ' + esc(JSON.stringify(args)) + '' : ''));
+ $('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' });
+ return m;
+}
+
+async function askLLM(v) {
scriptBusy = true;
- const sys = 'You are the planning assistant inside a trip-planning app. The user is refining a trip; the current plan state is below. ' +
- 'Answer the user\'s message concisely (2–5 sentences), using the plan as ground truth — never invent times, prices, or stops. ' +
- 'The user drives edits through the app UI; if an edit is implied, say which control does it (e.g. the chat command, the drawer chips on the map, a stop card\'s alternatives). ' +
- 'Plain text only, no markdown.\n\nCurrent plan:\n' + llmContext();
- fetch('/llm-chat', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: v }] }),
- })
- .then(r => r.json())
- .then(j => {
- const c = (j.choices?.[0]?.message?.content || '').trim();
- t.remove();
- msg('ai', c ? esc(c).replace(/\n+/g, '
')
- : 'The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
- scriptBusy = false;
- })
- .catch(() => { t.remove(); msg('ai', 'LLM unreachable right now — falling back to canned replies.'); scriptBusy = false; });
+ let t = typing();
+ const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }];
+ const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; };
+ 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 j = await r.json();
+ const content = (j.choices?.[0]?.message?.content || '').trim();
+ const call = parseToolBlock(content);
+ if (call) {
+ t.remove();
+ if (TOOLS[call.name]) toolLine(call.name, call.args || {});
+ t = typing(); // the assistant keeps "thinking"
+ messages.push({ role: 'assistant', content });
+ let res;
+ if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {}));
+ else res = { error: 'unknown tool. available: ' + Object.keys(TOOLS).join(', ') };
+ messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) });
+ continue;
+ }
+ finalText = content; break;
+ }
+ if (finalText === null) finish('I ran out of tool steps without a final answer — please rephrase.');
+ else if (finalText) finish(esc(finalText).replace(/\n+/g, '
'));
+ else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
+ } catch (e) {
+ t.remove();
+ msg('ai', 'LLM call failed — falling back to canned replies.');
+ scriptBusy = false;
+ }
}
// Google encoded-polyline decoder (OSRM `overview=full` geometry)
// OSRM encodes with precision 5 (unlike Google’s default 6)
diff --git a/mock/styles.css b/mock/styles.css
index c3025b9..bec0e4d 100644
--- a/mock/styles.css
+++ b/mock/styles.css
@@ -263,6 +263,8 @@ button { font: inherit; }
@keyframes pop { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.msg.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
.msg.ai { align-self: flex-start; background: #eef0f4; border-bottom-left-radius: 4px; }
+.msg.tool { align-self: flex-start; background: #f5f1fb; border: 1px solid #e5dbf2; border-radius: 10px; padding: 6px 10px; font-size: 11.5px; font-family: ui-monospace, Menlo, Consolas, monospace; color: #5d3a8a; max-width: 94%; }
+.msg.tool .ta { color: #8a6116; }
.msg .src { display: block; font-size: 11px; color: var(--ink2); margin-top: 6px; }
.msg .src a { color: var(--blue); text-decoration: none; }
.typing { display: inline-flex; gap: 4px; padding: 12px 14px; }