mock: server-side per-trip store — originals, parsed docs, conversation
Context and documents now live on the server between turns (and across reloads), per project, as requested: mock/store/<trip>/docs/<docId>-<file> ORIGINAL uploaded bytes mock/store/<trip>/docs/<docId>-<file>.txt extracted text (+ header) mock/store/<trip>/chat.jsonl full LLM conversation Endpoints: GET /trip-store/:trip (manifest incl. doc texts), GET .../doc/:docId, GET .../chat (last 100 turns), POST .../doc, POST .../chat. /parse-doc's extraction factored into shared finishParse() so both paths use identical logic. The store dir is gitignored (PII). Client: enterTrip rehydrates tripDocs + chatLog from the server (loadTripStore, race-guarded, one-time migration of localStorage docs from older builds), re-renders past turns in the chat panel, and askLLM persists each turn fire-and-forget. localStorage now carries only the plan state. Verified end-to-end: drop 133 KB email → direct re-anchor, original + parsed files on disk, both turns in chat.jsonl → full page reload → doc chip, document and conversation restored from the server → question asked without re-upload answered correctly (AK-77Q2ZD, 14C/22A). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
91e93ec265
commit
bf72fdf32d
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,3 +10,6 @@ osm/build/
|
|||
router/router
|
||||
router/bench
|
||||
router/scripts/__pycache__/
|
||||
|
||||
# per-trip uploaded documents + chat logs (PII — never commit)
|
||||
mock/store/
|
||||
|
|
|
|||
89
mock/app.js
89
mock/app.js
|
|
@ -449,10 +449,12 @@ function toolLine(name, args) {
|
|||
async function askLLM(v) {
|
||||
if (scriptBusy) return msg('ai', 'Hold on — I’m still working on the last request.');
|
||||
scriptBusy = true;
|
||||
await storeReady.catch(() => {}); // make sure the persisted history is loaded
|
||||
pushChatLog('user', v);
|
||||
persistChatTurn('user', v);
|
||||
let t = typing();
|
||||
const messages = [{ role: 'system', content: llmSystemPrompt() }, ...recentHistory(), { role: 'user', content: v }];
|
||||
const finish = (html, plain) => { t.remove(); msg('ai', html); if (plain) pushChatLog('assistant', plain); scriptBusy = false; };
|
||||
const finish = (html, plain) => { t.remove(); msg('ai', html); if (plain) { pushChatLog('assistant', plain); persistChatTurn('assistant', plain); } scriptBusy = false; };
|
||||
try {
|
||||
let finalText = null;
|
||||
for (let i = 0; i < LLM_TOOL_MAX; i++) {
|
||||
|
|
@ -1531,9 +1533,70 @@ $('#l3-close').onclick = closeL3;
|
|||
let version = 0;
|
||||
let history = []; // [{v, label, at, days, stays}]
|
||||
let hIdx = -1; // pointer into history
|
||||
let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents, persisted per trip
|
||||
let chatLog = []; // [{role:'user'|'assistant', text}] — recent turns, so a follow-up
|
||||
// ("yes, do it") can see what the model itself said earlier
|
||||
let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents
|
||||
// (authoritative copy lives on the server: mock/store/<trip>/docs/)
|
||||
let chatLog = []; // [{role:'user'|'assistant', text}] — conversation turns, persisted
|
||||
// per trip on the server (mock/store/<trip>/chat.jsonl) so a
|
||||
// follow-up ("yes, do it") can see what the model said earlier,
|
||||
// even after a reload
|
||||
let storeReady = Promise.resolve(); // resolve when this trip's server store has loaded
|
||||
let storeToken = 0; // guards against a stale load after switchTrip
|
||||
function persistChatTurn(role, text) {
|
||||
if (!tripId || !text || !String(text).trim()) return;
|
||||
fetch('/trip-store/' + encodeURIComponent(tripId) + '/chat', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role, text: String(text) }),
|
||||
}).catch(() => {}); // fire & forget — the model answer must not wait on the log
|
||||
}
|
||||
function loadTripStore(id) {
|
||||
const token = ++storeToken;
|
||||
storeReady = (async () => {
|
||||
try {
|
||||
const m = await (await fetch('/trip-store/' + encodeURIComponent(id))).json();
|
||||
if (token !== storeToken) return; // user switched trips while loading
|
||||
tripDocs = {};
|
||||
// one-time migration: docs saved in localStorage by older builds get
|
||||
// uploaded to the server (the extracted text becomes the stored file)
|
||||
const local = (tripStore.load(id) || {}).docs || {};
|
||||
const localNames = Object.keys(local);
|
||||
if (localNames.length && !(m.docs || []).length) {
|
||||
for (const n of localNames) {
|
||||
const d = local[n] || {};
|
||||
const b64 = btoa(unescape(encodeURIComponent(d.text || '')));
|
||||
const fn = /\.[a-z0-9]{1,5}$/i.test(n) ? n : n + '.txt';
|
||||
await fetch('/trip-store/' + encodeURIComponent(id) + '/doc', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: fn, mime: 'text/plain', b64 }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
const s = tripStore.load(id); // drop the migrated docs from localStorage
|
||||
if (s && s.docs) { delete s.docs; tripStore.save(id, s); }
|
||||
const m2 = await (await fetch('/trip-store/' + encodeURIComponent(id))).json();
|
||||
if (token !== storeToken) return;
|
||||
m.docs = m2.docs || [];
|
||||
}
|
||||
for (const d of m.docs || []) {
|
||||
if (!d.name) continue;
|
||||
tripDocs[d.name] = { name: d.name, kind: d.kind, chars: d.chars || 0, text: d.text || '', truncated: !!d.truncated, at: d.at || Date.now(), docId: d.docId, originalSize: d.originalSize };
|
||||
}
|
||||
const cj = await (await fetch('/trip-store/' + encodeURIComponent(id) + '/chat')).json();
|
||||
if (token !== storeToken) return;
|
||||
chatLog = (cj.messages || []).filter(x => x && x.text).map(x => ({ role: x.role === 'assistant' ? 'assistant' : 'user', text: String(x.text) }));
|
||||
renderDocChips();
|
||||
// re-render past turns — the chat panel is fresh after a reload (skip
|
||||
// the upload messages: they carry the whole document, not worth showing)
|
||||
const body = $('#chat-body');
|
||||
if (body) {
|
||||
const frag = document.createDocumentFragment();
|
||||
chatLog.filter(e => !e.text.includes('<document>')).slice(-40).forEach(e =>
|
||||
frag.append(el('div', 'msg ' + (e.role === 'assistant' ? 'ai' : 'user'),
|
||||
e.role === 'assistant' ? esc(e.text).replace(/\n+/g, '<br>') : esc(e.text))));
|
||||
if (frag.childNodes.length) body.prepend(frag);
|
||||
}
|
||||
} catch { /* store unreachable — fall back to in-memory only */ }
|
||||
})();
|
||||
return storeReady;
|
||||
}
|
||||
function pushChatLog(role, text) {
|
||||
if (!text || !String(text).trim()) return;
|
||||
chatLog.push({ role, text: String(text) });
|
||||
|
|
@ -1838,7 +1901,10 @@ async function ingestDoc(file) {
|
|||
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 }) });
|
||||
// save with the trip on the server (original + extracted text); fall back
|
||||
// to the stateless parser if the store endpoint is unavailable
|
||||
const url = tripId ? '/trip-store/' + encodeURIComponent(tripId) + '/doc' : '/parse-doc';
|
||||
const r = await fetch(url, { 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();
|
||||
|
|
@ -1867,8 +1933,8 @@ function renderDocChips() {
|
|||
function reconfigureFromDoc(j) {
|
||||
// keep the document on file: it persists with the trip and rides in the
|
||||
// model's context on every turn
|
||||
tripDocs[j.name] = { name: j.name, kind: j.kind, chars: j.chars, text: j.text, truncated: !!j.truncated, at: Date.now() };
|
||||
persistTrip(); renderDocChips();
|
||||
tripDocs[j.name] = { name: j.name, kind: j.kind, chars: j.chars, text: j.text, truncated: !!j.truncated, at: Date.now(), docId: j.docId || null, originalSize: j.originalSize || 0 };
|
||||
renderDocChips();
|
||||
msg('user', '📎 ' + esc(j.name) + ' <span class="src">' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — attached' + (j.truncated ? ' (truncated)' : '') + '</span>');
|
||||
if (!llmOn) return msg('ai', 'I read the document (' + (j.chars || 0) + ' chars) and kept it attached, but the model is offline so I can’t act on it yet. I’ll do it as soon as it’s back.');
|
||||
const prompt =
|
||||
|
|
@ -1944,7 +2010,9 @@ 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, bookings: M.trip.bookings, title: M.trip.title, docs: tripDocs }), 250);
|
||||
// docs + chat live on the server now (mock/store/<trip>/) — localStorage
|
||||
// only carries the plan state
|
||||
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 = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;
|
||||
|
|
@ -1976,10 +2044,11 @@ function enterTrip(id) {
|
|||
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;
|
||||
tripDocs = saved.docs ? deep(saved.docs) : {};
|
||||
} else tripDocs = {};
|
||||
}
|
||||
tripDocs = {}; // rehydrated from the server store below
|
||||
// reset per-trip UI state
|
||||
chatLog = []; compareItems = [];
|
||||
loadTripStore(id); // docs (original + parsed) + conversation, per trip
|
||||
closeDiscover(); closeL3();
|
||||
scope = 'day';
|
||||
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day'));
|
||||
|
|
|
|||
236
mock/server.js
236
mock/server.js
|
|
@ -36,6 +36,8 @@ const routerFor = (u) => ROUTERS[u.searchParams.get('router') || '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 STORE = path.join(__dirname, 'store'); // per-trip uploads (originals + parsed) + chat logs
|
||||
fs.mkdirSync(STORE, { recursive: true });
|
||||
|
||||
const mime = {
|
||||
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
||||
|
|
@ -58,6 +60,86 @@ function decodeEntities(s) {
|
|||
return Object.prototype.hasOwnProperty.call(ENT, e.toLowerCase()) ? ENT[e.toLowerCase()] : m;
|
||||
});
|
||||
}
|
||||
// extract document text: pdftotext for PDFs, MIME decoding for .eml,
|
||||
// tag-aware stripping for HTML. cb({ok:false,error}) or
|
||||
// cb({ok:true,kind,name,text,chars,truncated}) — text capped at 60k chars.
|
||||
const DOC_CAP = 60000; // well under the 131k-token context; flag if we had to cut
|
||||
function finishParse(in0, cb) {
|
||||
const name = String(in0.filename || 'document');
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const ctype = String(in0.mime || '');
|
||||
if (/^image\//.test(ctype) || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'].includes(ext))
|
||||
return cb({ 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.' });
|
||||
if (ext === '.msg')
|
||||
return cb({ ok: false, error: 'Outlook .msg files can’t read here — in your mail app use “Forward as HTML” (or Save As → .eml) and drop that instead.' });
|
||||
let buf = in0.buf;
|
||||
if (!buf) {
|
||||
try { buf = Buffer.from(String(in0.b64 || ''), 'base64'); } catch { return cb({ ok: false, error: 'couldn’t read the file data' }); }
|
||||
if (!buf.length) return cb({ ok: false, error: 'that file looks empty' });
|
||||
if (buf.length > 16e6) return cb({ ok: false, error: 'file too large (max ~16 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 cb({ ok: false, error: 'no readable text found in that file' });
|
||||
// collapse heavy repetition (boilerplate fare rules etc.): keep the first
|
||||
// two copies of any line that shows up 10+ times, then a single note
|
||||
const lines = text.split('\n'), count = {};
|
||||
lines.forEach(l => { const k = l.trim(); if (k) count[k] = (count[k] || 0) + 1; });
|
||||
const seen = {}, out = [];
|
||||
for (const l of lines) {
|
||||
const k = l.trim();
|
||||
if (k && count[k] >= 10) {
|
||||
seen[k] = (seen[k] || 0) + 1;
|
||||
if (seen[k] > 2) { if (out[out.length - 1] !== '__DUP__' + k) out.push('__DUP__' + k); continue; }
|
||||
}
|
||||
out.push(l);
|
||||
}
|
||||
text = out.map(l => l.startsWith('__DUP__') ? ' [… ' + count[l.slice(7)] + '× this line repeats — shown above]' : l).join('\n');
|
||||
try { fs.writeFileSync('/tmp/parse-doc-last.txt', 'file: ' + name + ' (' + buf.length + ' bytes) → ' + text.length + ' chars\n' + '=== extracted text (exactly what the model receives) ===\n' + text); } catch {}
|
||||
console.log(`[parse-doc] ${name} ${buf.length}B → ${text.length} chars${text.length > DOC_CAP ? ' (TRUNCATED at ' + DOC_CAP + ')' : ''} | preview: ${text.slice(0, 200).replace(/\n/g, ' ⏎ ')}`);
|
||||
cb({ ok: true, kind: ext.replace('.', '') || 'text', name, text: text.slice(0, DOC_CAP), chars: text.length, truncated: text.length > DOC_CAP });
|
||||
};
|
||||
if (ext === '.pdf' || ctype.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 cb({ 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 === '.eml' || looksLikeMime(text)) {
|
||||
const mimeBody = decodeMime(text);
|
||||
if (mimeBody != null) text = mimeBody; // not really MIME → treat as plain text
|
||||
}
|
||||
const head = text.slice(0, 20000);
|
||||
const tagDensity = (head.match(/<[a-z][\s\/]/gi) || []).length;
|
||||
if (ext === '.html' || ext === '.htm' || ctype.includes('html') || /<\s*(html|!doctype|head|body)\b/i.test(head) || tagDensity > 30)
|
||||
text = htmlToText(text);
|
||||
finish(text);
|
||||
}
|
||||
|
||||
// JSON body reader: cb(errOrNull, parsedObj)
|
||||
function readBody(req, cb, limit = 24e6) {
|
||||
let body = '', done = false;
|
||||
const finish = (err, obj) => { if (done) return; done = true; cb(err, obj); };
|
||||
req.on('data', c => {
|
||||
body += c;
|
||||
if (body.length > limit) { req.destroy(); finish('too large'); }
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (body.length > limit) return finish('too large');
|
||||
try { finish(null, JSON.parse(body)); } catch { finish('bad json'); }
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res) {
|
||||
return j => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify(j)); };
|
||||
}
|
||||
|
||||
function htmlToText(html) {
|
||||
const BLOCK_END = /^<\/(p|div|tr|table|h[1-6]|li|ul|ol|section|header|footer|blockquote|pre|article|main|form|dl|dd|figure)\s*>$/i;
|
||||
let out = '', i = 0, n = html.length;
|
||||
|
|
@ -281,68 +363,118 @@ http.createServer((req, res) => {
|
|||
// clean text with htmlToText(); txt/ics/csv/json read directly. Images
|
||||
// are rejected (no OCR on this box).
|
||||
if (u.pathname === '/parse-doc' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', c => { body += c; if (body.length > 24e6) 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 ctype = String(in0.mime || '');
|
||||
if (/^image\//.test(ctype) || ['.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.' });
|
||||
if (ext === '.msg')
|
||||
return send({ ok: false, error: 'Outlook .msg files can’t be read here — in your mail app use “Forward as HTML” (or Save As → .eml) and drop that instead.' });
|
||||
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 > 16e6) return send({ ok: false, error: 'file too large (max ~16 MB)' });
|
||||
const CAP = 60000; // well under the 131k-token context; flag if we had to cut
|
||||
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' });
|
||||
// collapse heavy repetition (boilerplate fare rules etc.): keep the first
|
||||
// two copies of any line that shows up 10+ times, then a single note
|
||||
const lines = text.split('\n'), count = {};
|
||||
lines.forEach(l => { const k = l.trim(); if (k) count[k] = (count[k] || 0) + 1; });
|
||||
const seen = {}, out = [];
|
||||
for (const l of lines) {
|
||||
const k = l.trim();
|
||||
if (k && count[k] >= 10) {
|
||||
seen[k] = (seen[k] || 0) + 1;
|
||||
if (seen[k] > 2) { if (out[out.length - 1] !== '__DUP__' + k) out.push('__DUP__' + k); continue; }
|
||||
readBody(req, (err, in0) => {
|
||||
if (err) { res.writeHead(400); return res.end('bad request'); }
|
||||
finishParse(in0, sendJson(res));
|
||||
});
|
||||
return;
|
||||
}
|
||||
out.push(l);
|
||||
|
||||
// ---- per-trip server store ------------------------------------------
|
||||
// Storage is cheap: for every trip we keep the ORIGINAL uploaded files,
|
||||
// the extracted text of each, and the full LLM conversation (jsonl).
|
||||
// store/<trip>/docs/<docId>-<filename> original bytes
|
||||
// store/<trip>/docs/<docId>-<filename>.txt extracted text + header
|
||||
// store/<trip>/chat.jsonl {role,text,at} per line
|
||||
if (u.pathname.startsWith('/trip-store/')) {
|
||||
const seg = u.pathname.slice('/trip-store/'.length).split('/');
|
||||
const tripId = decodeURIComponent(seg[0] || '');
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/i.test(tripId)) { res.writeHead(400); return res.end('bad trip id'); }
|
||||
const dir = path.join(STORE, tripId);
|
||||
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
||||
const json = sendJson(res);
|
||||
|
||||
if (req.method === 'GET' && seg[1] === undefined) {
|
||||
// manifest: docs + chat stats for this trip
|
||||
const docs = fs.readdirSync(path.join(dir, 'docs'))
|
||||
.filter(f => !f.endsWith('.txt'))
|
||||
.map(f => {
|
||||
const docId = f.split('-')[0];
|
||||
let name = f.slice(docId.length + 1), originalSize = 0, text = '', chars = 0, truncated = false;
|
||||
try { originalSize = fs.statSync(path.join(dir, 'docs', f)).size; } catch { originalSize = -1; }
|
||||
try {
|
||||
const t = fs.readFileSync(path.join(dir, 'docs', f + '.txt'), 'utf8');
|
||||
const nl = t.indexOf('\n');
|
||||
const m = t.slice(0, nl).match(/^file: (.+) \((\d+) bytes\) → (\d+) chars( \(truncated\))?/);
|
||||
if (m) { name = m[1]; originalSize = +m[2]; chars = +m[3]; truncated = !!m[4]; }
|
||||
text = t.slice(nl + 1);
|
||||
} catch {}
|
||||
const kind = path.extname(name).toLowerCase().replace('.', '') || 'text';
|
||||
return { docId, name, kind, originalSize, chars, truncated, text };
|
||||
});
|
||||
let chatCount = 0, chatBytes = 0;
|
||||
try {
|
||||
chatBytes = fs.statSync(path.join(dir, 'chat.jsonl')).size;
|
||||
chatCount = fs.readFileSync(path.join(dir, 'chat.jsonl'), 'utf8').split('\n').filter(Boolean).length;
|
||||
} catch {}
|
||||
return json({ trip: tripId, docs, chat: { count: chatCount, bytes: chatBytes } });
|
||||
}
|
||||
text = out.map(l => l.startsWith('__DUP__') ? ' [… ' + count[l.slice(7)] + '× this line repeats — shown above]' : l).join('\n');
|
||||
try { fs.writeFileSync('/tmp/parse-doc-last.txt', 'file: ' + name + ' (' + buf.length + ' bytes) → ' + text.length + ' chars\n' + '=== extracted text (exactly what the model receives) ===\n' + text); } catch {}
|
||||
console.log(`[parse-doc] ${name} ${buf.length}B → ${text.length} chars${text.length > CAP ? ' (TRUNCATED at ' + CAP + ')' : ''} | preview: ${text.slice(0, 200).replace(/\n/g, ' ⏎ ')}`);
|
||||
send({ ok: true, kind: ext.replace('.', '') || 'text', name, text: text.slice(0, CAP), chars: text.length, truncated: text.length > CAP });
|
||||
};
|
||||
if (ext === '.pdf' || ctype.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);
|
||||
|
||||
if (req.method === 'GET' && seg[1] === 'chat') {
|
||||
let messages = [];
|
||||
try {
|
||||
messages = fs.readFileSync(path.join(dir, 'chat.jsonl'), 'utf8')
|
||||
.split('\n').filter(Boolean).slice(-100)
|
||||
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
||||
.filter(Boolean);
|
||||
} catch {}
|
||||
return json({ messages });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && seg[1] === 'doc' && seg[2]) {
|
||||
const docId = seg[2];
|
||||
if (!/^[a-z0-9_-]+$/i.test(docId)) { res.writeHead(400); return res.end('bad doc id'); }
|
||||
const f = fs.readdirSync(path.join(dir, 'docs')).find(x => x.startsWith(docId + '-') && !x.endsWith('.txt'));
|
||||
if (!f) { res.writeHead(404); return res.end('no such doc'); }
|
||||
let name = f.slice(docId.length + 1), originalSize = 0, text = '';
|
||||
try { originalSize = fs.statSync(path.join(dir, 'docs', f)).size; } catch {}
|
||||
try {
|
||||
const t = fs.readFileSync(path.join(dir, 'docs', f + '.txt'), 'utf8');
|
||||
const nl = t.indexOf('\n');
|
||||
const m = t.slice(0, nl).match(/^file: (.+) \((\d+) bytes\) → (\d+) chars/);
|
||||
if (m) { name = m[1]; originalSize = +m[2]; }
|
||||
text = t.slice(nl + 1);
|
||||
} catch {}
|
||||
const kind = path.extname(name).toLowerCase().replace('.', '') || 'text';
|
||||
return json({ docId, name, kind, originalSize, text });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && seg[1] === 'doc') {
|
||||
readBody(req, (err, in0) => {
|
||||
if (err) return json({ ok: false, error: err === 'too large' ? 'file too large (max ~16 MB)' : 'bad request' });
|
||||
const rawName = String(in0.filename || 'document')
|
||||
.replace(/[\/\\*?"<>|\x00-\x1f]/g, '_').trim().slice(-80) || 'document';
|
||||
let buf; try { buf = Buffer.from(String(in0.b64 || ''), 'base64'); } catch { return json({ ok: false, error: 'couldn’t read the file data' }); }
|
||||
if (!buf.length) return json({ ok: false, error: 'that file looks empty' });
|
||||
finishParse({ filename: rawName, mime: in0.mime || '', buf }, (out) => {
|
||||
if (!out.ok) return json({ ok: false, error: out.error });
|
||||
const docId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
const base = path.join(dir, 'docs', docId + '-' + rawName);
|
||||
try {
|
||||
fs.writeFileSync(base, buf);
|
||||
fs.writeFileSync(base + '.txt', 'file: ' + rawName + ' (' + buf.length + ' bytes) → ' + out.chars + ' chars' + (out.truncated ? ' (truncated)' : '') + '\n=== extracted text ===\n' + out.text);
|
||||
} catch { return json({ ok: false, error: 'couldn’t store the document on the server' }); }
|
||||
console.log(`[store] ${tripId}/docs/${docId}-${rawName} ${buf.length}B → ${out.chars} chars`);
|
||||
json({ ok: true, docId, name: rawName, kind: out.kind, text: out.text, chars: out.chars, truncated: out.truncated, originalSize: buf.length });
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
let text = buf.toString('utf8');
|
||||
if (ext === '.eml' || looksLikeMime(text)) {
|
||||
const mimeBody = decodeMime(text);
|
||||
if (mimeBody != null) text = mimeBody; // not really MIME → treat as plain text
|
||||
}
|
||||
const head = text.slice(0, 20000);
|
||||
const tagDensity = (head.match(/<[a-z][\s\/a-z>]/gi) || []).length;
|
||||
if (ext === '.html' || ext === '.htm' || ctype.includes('html') || /<\s*(html|!doctype|head|body)\b/i.test(head) || tagDensity > 30)
|
||||
text = htmlToText(text);
|
||||
finish(text);
|
||||
|
||||
if (req.method === 'POST' && seg[1] === 'chat') {
|
||||
readBody(req, (err, in0) => {
|
||||
if (err) return json({ ok: false });
|
||||
const role = in0.role === 'assistant' ? 'assistant' : 'user';
|
||||
const text = String(in0.text || '');
|
||||
if (!text.trim()) return json({ ok: false });
|
||||
fs.appendFile(path.join(dir, 'chat.jsonl'), JSON.stringify({ role, text, at: Date.now() }) + '\n', e => json({ ok: !e }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404); return res.end('no such store route');
|
||||
}
|
||||
|
||||
// ---- static -------------------------------------------------------
|
||||
let p = u.pathname === '/' ? '/index.html' : u.pathname;
|
||||
const file = path.join(ROOT, p);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user