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 <noreply@anthropic.com>
220 lines
11 KiB
JavaScript
220 lines
11 KiB
JavaScript
// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached)
|
||
// + routing backend (proxy -> local OSRM instances; per-trip extracts).
|
||
// The UI only ever talks to localhost; swap the upstream for a real
|
||
// tileserver-gl / OSRM later without touching the frontend.
|
||
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;
|
||
// One upstream OSRM per dataset. `probe` is a short routable pair inside the
|
||
// extract, used by /router-status (health checks return Ok for 0-distance
|
||
// queries only when the graph has edges there).
|
||
const ROUTERS = {
|
||
northeast: {
|
||
url: process.env.OSRM_NORTHEAST || 'http://localhost:5000',
|
||
probe: '-71.06,42.35;-71.07,42.36', // Boston, car extract
|
||
},
|
||
colombia: {
|
||
url: process.env.OSRM_COLOMBIA || 'http://localhost:5003',
|
||
probe: '-75.5478,10.3954;-75.5400,10.4020', // Cartagena, foot extract
|
||
},
|
||
};
|
||
// LLM backend: local llama.cpp OpenAI-compatible server (see ~/.pi/agent/
|
||
// models.json). Model + budget are pinned server-side; the client only ever
|
||
// sends messages. Qwen3.8-27B-UD3-Q5-dual: --parallel 2, --ctx-size 131072.
|
||
const LLM = {
|
||
base: process.env.LLM_BASE || 'http://192.168.3.7:1234',
|
||
model: process.env.LLM_MODEL || 'Qwen3.8-27B-UD3-Q5-dual',
|
||
ctx: 131072,
|
||
maxTokens: +(process.env.LLM_MAX_TOKENS || 8192), // thinking + answer share this budget
|
||
};
|
||
const routerFor = (u) => ROUTERS[u.searchParams.get('router') || 'northeast'] || ROUTERS.northeast;
|
||
const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' };
|
||
const CACHE = path.join(__dirname, '.tilecache');
|
||
fs.mkdirSync(CACHE, { recursive: true });
|
||
|
||
const mime = {
|
||
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
||
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
||
};
|
||
|
||
http.createServer((req, res) => {
|
||
const u = new URL(req.url, 'http://x');
|
||
|
||
// ---- routing backend (proxy -> local OSRM) ------------------------
|
||
// /route?mode=foot&points=lng,lat;lng,lat[;...]
|
||
// /table?mode=foot&points=lng,lat;lng,lat[;...]
|
||
if (u.pathname === '/route' || u.pathname === '/table') {
|
||
const kind = u.pathname.slice(1);
|
||
const mode = u.searchParams.get('mode') || 'foot';
|
||
const points = u.searchParams.get('points') || '';
|
||
const profile = PROFILE[mode] || 'driving';
|
||
let osrmPath = `/${kind}/v1/${profile}/${encodeURIComponent(points)}`;
|
||
if (kind === 'route') osrmPath += '?overview=full&alternatives=false&steps=false';
|
||
else {
|
||
// table: durations from the first point (the anchor) to every point
|
||
const n = points.split(';').filter(Boolean).length;
|
||
const targets = Array.from({ length: n }, (_, i) => i).join(',');
|
||
osrmPath += `?annotations=duration,distance&sources=0&targets=${targets}`;
|
||
}
|
||
const up = routerFor(u).url;
|
||
const fetchUp = fetch(up + osrmPath).then(r => r.text()).catch(() => null);
|
||
fetchUp.then(body => {
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
res.setHeader('Content-Type', 'application/json');
|
||
if (body == null) { res.writeHead(502); return res.end(JSON.stringify({ code: 'ProxyError' })); }
|
||
res.writeHead(200); res.end(body);
|
||
});
|
||
return;
|
||
}
|
||
// routing availability probe (so the UI can label router vs estimate)
|
||
if (u.pathname === '/router-status') {
|
||
const rt = routerFor(u);
|
||
fetch(rt.url + '/route/v1/walking/' + rt.probe + '?overview=false').then(r => r.text())
|
||
.then(t => { let ok = false; try { ok = JSON.parse(t).code === 'Ok' && JSON.parse(t).routes?.[0]?.distance > 0; } catch (e) {}
|
||
res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: ok, osrm: rt.url, key: rt === ROUTERS.northeast ? 'northeast' : 'colombia' })); })
|
||
.catch(() => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: false, osrm: rt.url })); });
|
||
return;
|
||
}
|
||
|
||
// ---- tile backend -------------------------------------------------
|
||
if (u.pathname.startsWith('/tiles/')) {
|
||
const parts = u.pathname.split('/');
|
||
const [z, x, y] = [parts[2], parts[3], (parts[4] || '').replace(/\.png$/, '')];
|
||
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) { res.writeHead(400); return res.end(); }
|
||
const file = path.join(CACHE, `${z}/${x}`, `${y}.png`);
|
||
const upstream = `https://tile.openstreetmap.org/${z}/${x}/${y}.png`;
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
res.setHeader('Content-Type', 'image/png');
|
||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||
if (fs.existsSync(file)) { res.writeHead(200); fs.createReadStream(file).pipe(res); return; }
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
const fail = (code) => { if (!res.headersSent) { res.writeHead(code); res.end(); } else res.destroy(); };
|
||
// buffer the whole tile (≤100 KB), write the cache file, then reply —
|
||
// no stream-splitting races with a client that reads fast
|
||
fetch(upstream, { headers: { 'User-Agent': 'mapmock-dev/0.1 (local tile proxy)', 'Referer': `http://localhost:${PORT}/` } })
|
||
.then(async r => {
|
||
if (!r.ok) { return fail(r.status); }
|
||
const buf = Buffer.from(await r.arrayBuffer());
|
||
if (buf.length < 20) { return fail(502); } // empty/garbage tile
|
||
try { fs.writeFileSync(file, buf); } catch (e) {}
|
||
if (res.writableEnded) return;
|
||
res.writeHead(200); res.end(buf);
|
||
})
|
||
.catch(() => fail(502));
|
||
return;
|
||
}
|
||
|
||
// ---- LLM backend (proxy -> local llama.cpp server) ----------------
|
||
// GET /llm-status — availability probe (mirrors /router-status)
|
||
if (u.pathname === '/llm-status') {
|
||
fetch(LLM.base + '/v1/models').then(r => r.json())
|
||
.then(j => {
|
||
const m = (j.data || []).find(x => x.id === LLM.model);
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
res.setHeader('Content-Type', 'application/json');
|
||
res.writeHead(200);
|
||
res.end(JSON.stringify({
|
||
llm: !!(m && m.status && m.status.value === 'loaded'),
|
||
model: LLM.model, base: LLM.base, ctx: LLM.ctx,
|
||
status: (m && m.status && m.status.value) || 'unknown',
|
||
}));
|
||
})
|
||
.catch(() => {
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
res.setHeader('Content-Type', 'application/json');
|
||
res.writeHead(200); res.end(JSON.stringify({ llm: false, model: LLM.model, base: LLM.base, status: 'unreachable' }));
|
||
});
|
||
return;
|
||
}
|
||
// POST /llm-chat — body { messages, max_tokens?, temperature? }.
|
||
// Non-streaming; the model is a reasoning model, so the server-side budget
|
||
// must cover hidden thinking plus the visible answer.
|
||
if (u.pathname === '/llm-chat' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', c => { body += c; if (body.length > 2e6) req.destroy(); });
|
||
req.on('end', () => {
|
||
let in0;
|
||
try { in0 = JSON.parse(body); } catch { res.writeHead(400); return res.end('bad json'); }
|
||
const payload = {
|
||
model: LLM.model,
|
||
messages: Array.isArray(in0.messages) ? in0.messages : [],
|
||
max_tokens: in0.max_tokens || LLM.maxTokens,
|
||
temperature: in0.temperature ?? 0.7,
|
||
stream: false,
|
||
};
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
res.setHeader('Content-Type', 'application/json');
|
||
fetch(LLM.base + '/v1/chat/completions', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||
}).then(async r => {
|
||
const t = await r.text();
|
||
res.writeHead(r.ok ? 200 : 502);
|
||
res.end(t);
|
||
}).catch(() => {
|
||
res.writeHead(502);
|
||
res.end(JSON.stringify({ error: 'LLM server unreachable at ' + LLM.base }));
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
// ---- 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(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||
.replace(/<br[^>]*>/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);
|
||
if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
|
||
fs.readFile(file, (e, d) => {
|
||
if (e) { res.writeHead(404); return res.end('not found'); }
|
||
res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' });
|
||
res.end(d);
|
||
});
|
||
}).listen(PORT, '0.0.0.0', () => console.log(`map mock: http://localhost:${PORT} (tiles: /tiles/{z}/{x}/{y}.png · llm: ${LLM.base} ${LLM.model})`));
|