From 88dc0c5323e6017655e47fba5e3cdad2d6655dfd Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Wed, 9 Sep 2026 13:20:47 -0400 Subject: [PATCH] =?UTF-8?q?mock:=20drop=20a=20real=20flight=20booking=20?= =?UTF-8?q?=E2=86=92=20LLM=20re-anchors=20the=20trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user drags a flight ticket (PDF/text/ics/html) onto the chat panel, or uses the new 📎 button. The doc is text-extracted server-side (pdftotext for PDFs; images rejected with a clear "no OCR" message), then the LLM reads it, extracts the confirmed arrival + departure (date, local time, airport, city) and calls a new deterministic tool, apply_flight_anchors, which reconfigures the trip: - re-dates the day window across the booked [arrival, departure] span (contiguous when the day counts match, evenly spread when they don't) - records the flights as hard-anchor bookings, replacing placeholders - shortens the arrival day (start after landing + a real airport→hotel taxi time) and the departure day (ends exactly at the booked time) - trims stops that no longer fit and updates the trip title's range Undo/redo and reload persistence now version the trip's bookings + title alongside days/stays, so a re-configuration reverts and reloads cleanly. Server gains POST /parse-doc. Verified E2E (LLM extraction + reconfigure), idempotency, drag-and-drop, and undo; non-flight agent unaffected. Co-Authored-By: Claude --- mock/app.js | 244 +++++++++++++++++++++++++++++++++++++++++++++++- mock/index.html | 4 +- mock/server.js | 46 +++++++++ mock/styles.css | 11 ++- 4 files changed, 298 insertions(+), 7 deletions(-) diff --git a/mock/app.js b/mock/app.js index 38a4a7a..ed39726 100644 --- a/mock/app.js +++ b/mock/app.js @@ -114,6 +114,75 @@ function resolveStop(ref) { return null; } +// ---- flight-anchor reconfiguration: the user drops a real ticket and the +// plan re-anchors around the booked arrival/departure (times + locations) ---- +const AIRPORTS = { + bog: [4.7016, -74.1469], ctg: [10.4622, -75.4385], smr: [11.1165, -74.2330], + mtr: [8.7443, -75.8744], clo: [1.2804, -77.1733], elc: [6.8731, -71.6474], + mde: [6.2447, -75.5801], baq: [3.5757, -76.5716], cuc: [5.6958, -75.6504], + mia: [25.7959, -80.2870], jfk: [40.6413, -73.7781], lax: [33.9416, -118.4085], + sfo: [37.6213, -122.3790], yyz: [43.6771, -79.6240], yvr: [49.1939, -123.1833], + lhr: [51.4700, -0.4543], cdg: [49.0097, 2.5479], ams: [52.3105, 4.7683], + fra: [50.0379, 8.5622], mad: [40.4983, -3.5676], bcn: [41.2974, 2.0833], + zrh: [47.4647, 8.5492], mex: [19.4363, -99.0721], lim: [-12.0219, -77.1143], +}; +const ARR_DAY_END = 21 * 60 + 30; // an arrival day should wrap by ~21:30 +const DOWS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function parseHM(t) { + const m = /^(\d{1,2}):(\d{2})$/.exec(String(t || '').trim()); + if (!m) return null; + const h = +m[1], mn = +m[2]; + return (h > 23 || mn > 59) ? null : h * 60 + mn; +} +const CITY_AIRPORT = { + 'santa marta': 'smr', cartagena: 'ctg', marta: 'smr', bogota: 'bog', 'bogotá': 'bog', + barranquilla: 'baq', medellin: 'mde', 'medellĂ­n': 'mde', 'santa fe': 'mde', + 'el banquete': 'elc', palmira: 'cvc', cali: 'cvc', toronto: 'yyz', + 'new york': 'jfk', london: 'lhr', paris: 'cdg', amsterdam: 'ams', frankfurt: 'fra', + madrid: 'mad', barcelona: 'bcn', zurich: 'zrh', 'zĂĽrich': 'zrh', mexico: 'mex', lima: 'lim', +}; +function airportOf(ref) { + if (!ref) return null; + const s = String(ref).trim(), up = s.toUpperCase(); + const code = (up.match(/\b([A-Z]{3})\b/) || [])[1]; + let key = code ? code.toLowerCase() : null; + if (!key) { const cl = s.toLowerCase(); for (const [city, c] of Object.entries(CITY_AIRPORT)) if (cl.includes(city)) { key = c; break; } } + const hit = key && AIRPORTS[key]; + return hit ? { code: code || key.toUpperCase(), at: hit } : null; +} +function driveMin(ap, hotelAt) { // rough airport<->hotel taxi time + if (!ap || !hotelAt) return null; + const km = haversine(ap.at, hotelAt) / 1000; + return Math.min(90, Math.max(20, Math.round(km / 35 * 60))); +} +function relabelDay(d, idx, dateStr) { + const [y, mo, da] = String(dateStr).split('-').map(Number); + const dt = new Date(Date.UTC(y, mo - 1, da)); + d.date = dateStr; + d.label = 'Day ' + idx + ' · ' + DOWS[dt.getUTCDay()] + ' ' + da; +} +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)) : ''; +} +// drop stops (visits first; keep breakfast/meals/transit) until the day fits endMin +function trimDayTo(d, endMin, removed) { + rebuildLegs(d); + let guard = 0; + while (layout(d) > endMin && d.stops.length > 1 && guard++ < 60) { + let idx = -1; + for (let i = d.stops.length - 1; i >= 0; i--) if (d.stops[i].slot === 'visit') { idx = i; break; } + if (idx < 0) for (let i = d.stops.length - 1; i >= 0; i--) + if (d.stops[i].slot !== 'breakfast' && d.stops[i].slot !== 'transit') { idx = i; break; } + if (idx < 0) break; + removed.push(d.stops[idx].name); + d.stops.splice(idx, 1); + rebuildLegs(d); + } +} + const TOOLS = { // ---- queries (read-only) ---- itinerary_summary: () => ({ plan: llmContext() }), @@ -227,6 +296,109 @@ const TOOLS = { 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)) }; }, + + // 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 + // departure day (wrap before the flight), and trims what no longer fits. + apply_flight_anchors: (a) => { + const arr = a.arrival || {}, dep = a.departure || {}; + const arrMin = parseHM(arr.time), depMin = parseHM(dep.time); + if (arrMin == null) return { error: 'arrival.time must be "HH:MM" (got "' + arr.time + '")' }; + if (depMin == null) return { error: 'departure.time must be "HH:MM" (got "' + dep.time + '")' }; + const ids = Object.keys(days).sort(); + const first = days[ids[0]], last = days[ids[ids.length - 1]]; + const hotelFirst = (first.base && first.base.at) || (stays[0] && stays[0].at); + const hotelLast = (last.base && last.base.at) || (stays[stays.length - 1] && stays[stays.length - 1].at); + const arrAp = airportOf(arr.airport), depAp = airportOf(dep.airport); + const arrTransfer = driveMin(arrAp, hotelFirst) != null ? driveMin(arrAp, hotelFirst) : 60; // land → hotel + const depTransfer = driveMin(depAp, hotelLast) != null ? driveMin(depAp, hotelLast) : 60; // hotel → airport + const removed = [], notes = []; + + // 1) re-date across the real [arrival, departure] window — spread the plan's + // days evenly between the booked first and last date (contiguous when the + // day counts match, monotonic when they don't) + if (arr.date && dep.date) { + const pa = (y, m, d) => Date.UTC(+y, m - 1, +d); + const [ay, am, ad] = arr.date.split('-'), [by, bm, bd] = dep.date.split('-'); + const A = pa(ay, am, ad), B = pa(by, bm, bd); + const N = ids.length; + if (B >= A) { + const spanDays = Math.round((B - A) / 86400000) + 1; + if (N === 1) { if (first.date !== arr.date) relabelDay(first, 1, arr.date); } + else { + for (let i = 0; i < N; i++) + relabelDay(days[ids[i]], i + 1, new Date(A + Math.round(i * (B - A) / (N - 1))).toISOString().slice(0, 10)); + if (spanDays !== N) notes.push('the booked window is ' + spanDays + ' days but the plan has ' + N + ' — I spread the ' + N + ' days evenly across ' + rangeStr(arr.date, dep.date)); + } + } + } + + // 2) record the booked flights as hard anchors — the user's real ticket + // supersedes any placeholder flight records (hotels stay untouched); + // dropping all flights first keeps a re-apply idempotent + M.trip.bookings = (M.trip.bookings || []).filter(b => b.type !== 'flight'); + const flightRec = (role, o, ap, t) => ({ + type: 'flight', source: 'user_flight', status: 'booked', + name: (o.flight ? o.flight + ' · ' : '') + (ap ? ap.code : (o.city || 'flight')), + ref: o.ref || (role === 'arrival' ? 'arr-booking' : 'dep-booking'), + from: o.from || '—', to: o.to || '—', date: o.date || '', + depart: role === 'departure' ? t : (o.depart || ''), arrive: role === 'arrival' ? t : (o.arrive || ''), + stations: ap ? { from: role === 'arrival' ? null : ap.at, to: role === 'arrival' ? ap.at : null } : {}, + }); + M.trip.bookings.push(flightRec('arrival', arr, arrAp, arr.time)); + M.trip.bookings.push(flightRec('departure', dep, depAp, dep.time)); + const rng = rangeStr(arr.date, dep.date); + if (rng) { + // idempotent: replace a trailing date range in any of the forms we emit or + // the dataset uses ("30 Sep–12 Oct" | "21–28 Sep" | "21 Sep") + const RE = /(\d{1,2}\s+[A-Za-z]{3}\s*[\u2013\u2014-]\s*\d{1,2}\s+[A-Za-z]{3}|\d{1,2}\s*[\u2013\u2014-]\s*\d{1,2}\s+[A-Za-z]{3}|\d{1,2}\s+[A-Za-z]{3})\s*$/; + const t = M.trip.title.replace(RE, rng); + if (t !== M.trip.title) M.trip.title = t; + } + + // 3) arrival day: start after landing + transfer; drop the meaningless + // breakfast; shorten the waking window; trim what no longer fits + first.startMin = arrMin + arrTransfer; + for (let i = first.stops.length - 1; i >= 0; i--) + if (first.stops[i].slot === 'breakfast') { removed.push(first.stops[i].name); first.stops.splice(i, 1); } + const arrEnd = Math.min(ARR_DAY_END, first.startMin + (first.wakingHours || 12) * 60); + first.wakingHours = Math.max(2, Math.round((arrEnd - first.startMin) / 60)); + trimDayTo(first, first.startMin + first.wakingHours * 60, removed); + + // 4) departure day: keep only check-out + the flight; size the flight block + // so the day lands exactly on the booked departure time + last.isDeparture = true; + last.startMin = Math.min(last.startMin || 540, 8 * 60); + last.stops.forEach(s => { if (s.slot !== 'transit' && s.slot !== 'breakfast') removed.push(s.name); }); + last.stops = last.stops.filter(s => s.slot === 'transit' || s.slot === 'breakfast'); + if (!last.stops.some(s => s.slot === 'breakfast')) + last.stops.unshift({ id: 'CHKOUT', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', slot: 'breakfast', state: 'planned', at: hotelLast || [0, 0], dur: 45, durConf: 3 }); + if (!last.stops.some(s => s.slot === 'transit')) + last.stops.push({ id: 'FLYOUT', name: 'Flight out — ' + (dep.airport || 'airport'), kind: 'transit', slot: 'transit', state: 'planned', at: (depAp && depAp.at) || hotelLast || [0, 0], dur: 60, durConf: 3 }); + rebuildLegs(last); + let pre = last.startMin; + for (let i = 0; i < last.stops.length; i++) { + const s = last.stops[i]; + if (s.slot === 'transit') { s.dur = Math.max(30, depMin - pre); break; } + pre += s.dur; if (last.legs[i]) pre += last.legs[i].dur; + } + last.wakingHours = Math.max(1, Math.round((depMin - last.startMin) / 60)); + + // 5) commit + ids.forEach(id => rebuildLegs(days[id])); + commit('[flight] re-anchored around booked arrival + departure'); + renderAll(); + const fs2 = dayStats(first), ls2 = dayStats(last); + return { + ok: true, + arrival: { city: arr.city, airport: arr.airport, date: arr.date, time: arr.time, land_to_hotel_min: arrTransfer }, + departure: { city: dep.city, airport: dep.airport, date: dep.date, time: dep.time, hotel_to_airport_min: depTransfer }, + first_day: { id: first.id, label: first.label, start: fmt(first.startMin), end: fmt(fs2.end), stops: first.stops.length }, + last_day: { id: last.id, label: last.label, start: fmt(last.startMin), end: fmt(ls2.end), stops: last.stops.length }, + removed: removed, notes: notes, + }; + }, }; function llmSystemPrompt() { @@ -247,14 +419,16 @@ function llmSystemPrompt() { ' 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' + + ' set_start(dayId, time) — set a day\'s start time as "HH:MM".\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.\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' + + '- 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' + + '- When a real flight/booking document is attached, read out the arrival and departure (date, local time, airport, city), then call apply_flight_anchors exactly once. If the document is ambiguous or its dates don’t match the plan’s, ask me to confirm before applying.\n\n' + 'Current plan:\n' + llmContext()); } @@ -1356,7 +1530,7 @@ let history = []; // [{v, label, at, days, stays}] let hIdx = -1; // pointer into history const commit = (label) => { history = history.slice(0, hIdx + 1); // drop any redo branch - history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays) }); + history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays), bookings: deep(M.trip.bookings), title: M.trip.title }); hIdx = history.length - 1; if (history.length > 60) { history.shift(); hIdx--; } renderVersionPill(); @@ -1368,6 +1542,8 @@ function restore(idx, silent) { Object.keys(days).forEach(k => { Object.assign(days[k], deep(s.days[k])); }); stays = deep(s.stays); staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1); + if (s.bookings) M.trip.bookings = deep(s.bookings); + if (s.title != null) M.trip.title = s.title; closeDiscover(); closeL3(); rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs renderHotelMks(); renderBaseChip(); @@ -1620,6 +1796,60 @@ function handleUser(v) { $('#chat-send').onclick = chatSend; $('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend()); +// ---- flight/booking ingestion: drop a real ticket, the model re-anchors ---- +function fileToB64(file) { + return new Promise((res, rej) => { + const r = new FileReader(); + r.onload = () => res(String(r.result).split(',')[1] || ''); + r.onerror = () => rej(r.error); + r.readAsDataURL(file); + }); +} +async function ingestDoc(file) { + if (!file) return; + if (scriptBusy) { msg('ai', 'One moment — I’m still working on the last request.'); return; } + const note = msg('user', '📎 ' + esc(file.name) + ' · ' + Math.max(1, Math.round(file.size / 1024)) + ' KB — reading…'); + let b64; + try { b64 = await fileToB64(file); } + catch { note.remove(); return msg('ai', 'Couldn’t read that file.'); } + try { + const r = await fetch('/parse-doc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, mime: file.type || '', b64 }) }); + const j = await r.json(); + if (!j.ok) { note.remove(); return msg('ai', '⚠️ ' + esc(j.error || 'couldn’t read that file')); } + note.remove(); + reconfigureFromDoc(j); + } catch { note.remove(); msg('ai', 'The document reader is unreachable right now.'); } +} +function reconfigureFromDoc(j) { + msg('user', '📎 ' + esc(j.name) + ' ' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — reconfigure the trip around my flights'); + if (!llmOn) return msg('ai', 'I read the booking (' + (j.chars || 0) + ' chars), but the model is offline so I can’t reconfigure around it yet. I’ll do it as soon as it’s back.'); + const prompt = + 'I’ve attached my real flight booking for this trip (extracted text below).\n\n' + + 'DOCUMENT “' + (j.name || 'booking') + '”\n' + + '\n' + (j.text || '') + '\n\n\n' + + 'Reconfigure this trip around it:\n' + + '1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city).\n' + + '2. Call apply_flight_anchors exactly once with those two anchors.\n' + + '3. Confirm what changed — the new first and last day with their start/end times, and any stops that had to go. Keep it to a couple of sentences.'; + askLLM(prompt); +} +// drag & drop a file anywhere on the chat panel +(function () { + const chat = $('#chat'); if (!chat) return; + const hasFiles = e => e.dataTransfer && Array.prototype.slice.call(e.dataTransfer.types || []).indexOf('Files') >= 0; + chat.addEventListener('dragover', e => { if (hasFiles(e)) { e.preventDefault(); chat.classList.add('dropping'); } }); + chat.addEventListener('dragenter', e => { if (hasFiles(e)) { e.preventDefault(); chat.classList.add('dropping'); } }); + chat.addEventListener('dragleave', e => { if (!chat.contains(e.relatedTarget)) chat.classList.remove('dropping'); }); + chat.addEventListener('drop', e => { e.preventDefault(); chat.classList.remove('dropping'); const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) ingestDoc(f); }); +})(); +// 📎 attach button (same path as drag & drop) +const docFile = $('#doc-file'); +const docAttach = $('#doc-attach'); +if (docAttach && docFile) { + docAttach.onclick = () => docFile.click(); + docFile.addEventListener('change', e => { const f = e.target.files && e.target.files[0]; if (f) ingestDoc(f); e.target.value = ''; }); +} + // ---------------- landing → parse → plan ---------------- // parsed-booking preview, derived from the active dataset (city-aware) function sampleRows() { @@ -1665,7 +1895,7 @@ let persistT = 0; function persistTrip() { clearTimeout(persistT); const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25 - persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist }), 250); + persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title }), 250); } function renderTripMenu() { $('#crumb').innerHTML = `đź§ł ${M.trip.title} â–ľ`; @@ -1693,7 +1923,11 @@ function enterTrip(id) { 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) { + 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; + } // reset per-trip UI state compareItems = []; closeDiscover(); closeL3(); diff --git a/mock/index.html b/mock/index.html index 89ffdce..3191a70 100644 --- a/mock/index.html +++ b/mock/index.html @@ -112,7 +112,9 @@ diff --git a/mock/server.js b/mock/server.js index 1630091..50405b7 100644 --- a/mock/server.js +++ b/mock/server.js @@ -5,6 +5,8 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); +const os = require('os'); +const { execFile } = require('child_process'); const PORT = 8077; const ROOT = __dirname; @@ -161,6 +163,50 @@ http.createServer((req, res) => { return; } + // ---- document ingestion (flight/booking → text) -------------------- + // POST /parse-doc — body { filename, mime, b64 }. Returns { ok, kind, + // name, text, chars }. PDFs go through pdftotext; text/ics/csv/json read + // directly; html tags are stripped. Images are rejected (no OCR here). + if (u.pathname === '/parse-doc' && req.method === 'POST') { + let body = ''; + req.on('data', c => { body += c; if (body.length > 8e6) req.destroy(); }); + req.on('end', () => { + let in0; try { in0 = JSON.parse(body); } catch { res.writeHead(400); return res.end('bad json'); } + const send = j => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify(j)); }; + const name = String(in0.filename || 'document'); + const ext = path.extname(name).toLowerCase(); + const mime = String(in0.mime || ''); + if (/^image\//.test(mime) || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'].includes(ext)) + return send({ ok: false, error: 'Screenshots and images aren’t supported yet (no OCR on this box). Save the booking as a PDF, or copy its text and paste it.' }); + let buf; try { buf = Buffer.from(String(in0.b64 || ''), 'base64'); } catch { return send({ ok: false, error: 'couldn’t read the file data' }); } + if (!buf.length) return send({ ok: false, error: 'that file looks empty' }); + if (buf.length > 4e6) return send({ ok: false, error: 'file too large (max ~4 MB)' }); + const finish = (text) => { + text = String(text || '').replace(/\r\n/g, '\n').replace(/\t/g, ' ').replace(/[ \u00a0]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); + if (!text) return send({ ok: false, error: 'no readable text found in that file' }); + send({ ok: true, kind: ext.replace('.', '') || 'text', name, text: text.slice(0, 20000), chars: text.length }); + }; + if (ext === '.pdf' || mime.includes('pdf')) { + const tmp = path.join(os.tmpdir(), 'parse-doc-' + Date.now() + '-' + Math.floor(Math.random() * 1e5) + '.pdf'); + fs.writeFile(tmp, buf, (werr) => { + if (werr) return send({ ok: false, error: 'couldn’t write temp file' }); + execFile('pdftotext', ['-layout', tmp, '-'], (err, stdout) => { + try { fs.unlinkSync(tmp); } catch {} + finish(err ? '' : stdout); + }); + }); + return; + } + let text = buf.toString('utf8'); + if (ext === '.html' || ext === '.htm' || mime.includes('html')) + text = text.replace(//gi, ' ').replace(//gi, ' ') + .replace(/]*>/gi, '\n').replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'|'/g, "'").replace(/"/g, '"'); + finish(text); + }); + return; + } + // ---- static ------------------------------------------------------- let p = u.pathname === '/' ? '/index.html' : u.pathname; const file = path.join(ROOT, p); diff --git a/mock/styles.css b/mock/styles.css index bec0e4d..c64f4fe 100644 --- a/mock/styles.css +++ b/mock/styles.css @@ -80,7 +80,16 @@ button { font: inherit; } .mapwrap, #mapwrap { position: relative; pointer-events: none; } /* let wheel/drag reach the Leaflet map underneath */ #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); } +#chat-input-row { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--line); align-items: center; } +#doc-attach { cursor: pointer; line-height: 1; } +#chat { position: relative; } +#chat.dropping::after { + content: 'Drop the flight booking to reconfigure the trip'; + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + font: 600 15px/1.4 var(--sans, system-ui, sans-serif); color: #2f6df6; text-align: center; + background: rgba(238, 244, 255, 0.88); border: 3px dashed #2f6df6; border-radius: 12px; + pointer-events: none; z-index: 20; padding: 24px; +} #chat-input { flex: 1; border: 1.5px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 13.5px; outline: none; } #chat-input:focus { border-color: var(--accent); }