Agent Template

Messy Itinerary Travel Planner

Drop your messy pile of booking PDFs, confirmation emails, and trip notes and get a clean day-by-day itinerary — including warnings about gaps like a 14:00 landing vs a 16:00 check-in — without your bookings ever leaving your browser.

travel itinerary planning documents rag privacy
ozzo Jul 12, 2026 1 use

Preview Mode

This is a preview with sample data. The template uses placeholders like which will be replaced with actual agent data.

About This Template

Messy Itinerary Travel Planner is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.

Topics travel itinerary planning documents rag privacy
Template Preview

Template Metadata

Slug
travel-itinerary-planner
Created By
ozzo
Created
Jul 12, 2026
Usage Count
1

Tags

travel itinerary planning documents rag privacy

Code Statistics

HTML Lines
39
CSS Lines
58
JS Lines
126
Python Lines
122

Source Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ agent_name }}</title>
  <style>{{ css_code }}</style>
</head>
<body>
  <main class="tp">
    <header class="tp__head">
      <h1>🧳 {{ agent_name }}</h1>
      <p class="tp__sub">{{ description }}</p>
    </header>

    <section class="tp__upload">
      <label class="tp__drop" for="trip-file" id="tp-drop">
        <input id="trip-file" type="file"
               accept=".pdf,.txt,.md,.markdown,.eml" multiple hidden>
        <span>✈️ Drop your flight PDFs, hotel emails, and notes here — the whole messy pile</span>
      </label>
      <div id="tp-files" class="tp__files" aria-label="Trip files"></div>
      <div id="tp-status" class="tp__status" role="status"></div>
      <button id="tp-build" type="button" class="tp__build" disabled>
        🗓️ Build my itinerary
      </button>
    </section>

    <section id="results-container" class="tp__chat" aria-live="polite"></section>

    <form id="tp-form" class="tp__form" autocomplete="off">
      <input id="tp-input" type="text"
             placeholder="Ask about your trip — timings, booking refs, hotels…" required disabled>
      <button id="tp-send" type="submit" disabled>Ask</button>
    </form>
  </main>
  <script>{{ js_code }}</script>
</body>
</html>
:root {
  color-scheme: light dark;
  --tp-primary: #0ea5e9;   /* sky blue */
  --tp-accent: #f97316;    /* adventure orange */
  --tp-bg: #f0f9ff;
  --tp-surface: #ffffff;
  --tp-text: #0c4a6e;
  --tp-muted: #64748b;
  --tp-border: #dbeafe;
  --tp-radius: 12px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
       background: var(--tp-bg); color: var(--tp-text); line-height: 1.55; }
.hidden { display: none !important; }
.tp { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.tp__head h1 { font-size: 1.55rem; margin: 0 0 4px; }
.tp__sub { margin: 0 0 18px; color: var(--tp-muted); }
.tp__drop { display: block; border: 2px dashed #9ed3f2; border-radius: var(--tp-radius);
            padding: 22px; text-align: center; cursor: pointer;
            background: var(--tp-surface); color: #2b6183;
            transition: border-color .15s, background .15s; }
.tp__drop:hover, .tp__drop--over { border-color: var(--tp-primary); background: #e6f5fe; }
.tp__files { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
.tp__file { font-size: .8rem; background: var(--tp-surface); color: var(--tp-text);
            border: 1px solid var(--tp-border); border-radius: 999px; padding: 4px 11px; }
.tp__status { font-size: .85rem; color: var(--tp-muted); margin: 10px 2px;
              min-height: 1.2em; }
.tp__build { margin: 4px 2px 0; padding: 11px 18px; border: 0; border-radius: 10px;
             background: var(--tp-accent); color: #fff; font-weight: 700;
             cursor: pointer; }
.tp__build:disabled { background: #b9c8d4; cursor: not-allowed; }
.tp__chat { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 16px; }
.tp__msg, .message { padding: 11px 14px; border-radius: var(--tp-radius);
                     max-width: 88%; white-space: pre-wrap; line-height: 1.55; }
.tp__msg--user { align-self: flex-end; background: var(--tp-primary); color: #fff; }
.tp__msg--assistant, .message-assistant { align-self: flex-start;
    background: var(--tp-surface); border: 1px solid var(--tp-border); }
.message { display: flex; gap: 8px; }
.message-icon { flex: 0 0 auto; }
.message-content { min-width: 0; }
.tp__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
            padding: 12px 16px; background: var(--tp-surface);
            border-top: 1px solid var(--tp-border); }
.tp__form input { flex: 1; padding: 12px 14px; border: 1px solid #b6d9ee;
                  border-radius: 10px; font-size: 1rem;
                  background: var(--tp-bg); color: var(--tp-text); }
.tp__form input:focus-visible { outline: 2px solid var(--tp-primary); outline-offset: 1px; }
.tp__form button { padding: 0 22px; border: 0; border-radius: 10px;
                   background: var(--tp-primary); color: #fff; font-weight: 700;
                   cursor: pointer; }
.tp__form button:disabled { background: #b9c8d4; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
  :root { --tp-bg: #0c1720; --tp-surface: #14222e; --tp-text: #dcecf7;
          --tp-muted: #92a7b7; --tp-border: #23384a; }
  .tp__drop { color: #a8c6da; border-color: #2f4d63; }
  .tp__drop:hover, .tp__drop--over { background: #18293a; }
}
// Travel Planner UI wiring. The RAG/embeddings/wllama infrastructure is
// injected by the generator; this connects upload, build, and chat controls.
(function () {
  const statusEl = () => document.getElementById('tp-status');
  const results = () => document.getElementById('results-container');
  const input = () => document.getElementById('tp-input');
  const sendBtn = () => document.getElementById('tp-send');
  const buildBtn = () => document.getElementById('tp-build');
  const filesEl = () => document.getElementById('tp-files');
  const dropZone = () => document.getElementById('tp-drop');

  let pyReady = false;
  let fileCount = 0;

  // Render helper the wllama query bridge also calls for streamed responses.
  window.addMessage = function (type, content) {
    const el = document.createElement('div');
    el.className = 'tp__msg tp__msg--' + (type === 'user' ? 'user' : 'assistant');
    el.textContent = content;
    results().appendChild(el);
    el.scrollIntoView({ behavior: 'smooth', block: 'end' });
    return el;
  };

  function setStatus(msg) { statusEl().textContent = msg; }
  function enableControls(on) {
    input().disabled = !on;
    sendBtn().disabled = !on;
    buildBtn().disabled = !on;
  }

  async function readFileText(file) {
    if (/\.pdf$/i.test(file.name)) {
      const buf = new Uint8Array(await file.arrayBuffer());
      window.pyodide.globals.set('__tp_pdf_bytes', buf);
      return await window.pyodide.runPythonAsync('_extract_pdf_text(bytes(__tp_pdf_bytes.to_py()))');
    }
    return await file.text();
  }

  async function ingest(file) {
    setStatus('Reading "' + file.name + '"…');
    const text = await readFileText(file);
    if (!text.trim()) throw new Error('no selectable text found (scanned PDF?)');
    window.pyodide.globals.set('__tp_src', file.name);
    window.pyodide.globals.set('__tp_text', text);
    const raw = await window.pyodide.runPythonAsync('await _ingest_trip_file(__tp_src, __tp_text)');
    const info = JSON.parse(raw);
    const chip = document.createElement('span');
    chip.className = 'tp__file';
    chip.textContent = '📎 ' + file.name + ' · ' + info.facts + ' schedule clue' +
      (info.facts === 1 ? '' : 's');
    filesEl().appendChild(chip);
    fileCount++;
    setStatus(fileCount + ' file' + (fileCount === 1 ? '' : 's') +
      ' in. Add more, or load a model in the top bar and build the itinerary.');
    enableControls(true);
  }

  async function handleFiles(files) {
    if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
    for (const file of files) {
      try { await ingest(file); }
      catch (err) { setStatus('Could not read "' + file.name + '": ' + err); }
    }
  }

  document.getElementById('trip-file').addEventListener('change', (e) => {
    handleFiles(e.target.files);
  });
  dropZone().addEventListener('dragover', (e) => {
    e.preventDefault();
    dropZone().classList.add('tp__drop--over');
  });
  dropZone().addEventListener('dragleave', () => {
    dropZone().classList.remove('tp__drop--over');
  });
  dropZone().addEventListener('drop', (e) => {
    e.preventDefault();
    dropZone().classList.remove('tp__drop--over');
    handleFiles(e.dataTransfer.files);
  });

  buildBtn().addEventListener('click', async () => {
    if (!pyReady || !fileCount) return;
    window.addMessage('user', 'Build my itinerary');
    enableControls(false);
    try {
      const plan = await window.pyodide.runPythonAsync('await _build_itinerary()');
      if (plan) window.addMessage('assistant', plan);
    } catch (err) {
      window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
    } finally {
      enableControls(true);
    }
  });

  document.getElementById('tp-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const q = input().value.trim();
    if (!q || !pyReady) return;
    window.addMessage('user', q);
    input().value = '';
    enableControls(false);
    try {
      window.pyodide.globals.set('__tp_q', q);
      const answer = await window.pyodide.runPythonAsync('await process_user_query(__tp_q)');
      if (answer) window.addMessage('assistant', answer);
    } catch (err) {
      window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
    } finally {
      enableControls(true);
      input().focus();
    }
  });

  // Pyodide is ready after initAgent(); the orchestrator fires this on document.
  document.addEventListener('pyodide-ready', async () => {
    // Start each session with an empty index: the RAG store is shared per
    // origin, and this trip's answers must not mix with an earlier trip's
    // files (or another document agent on the same host).
    try { await window.AgentOpRAG.clear(); } catch (e) {}
    pyReady = true;
    setStatus('Ready. Drop your trip files to begin — they never leave this browser.');
  });
})();
import json
import re

# How many document passages to retrieve per follow-up question.
RAG_TOP_K = [[[RAG_TOP_K|5]]]
# Cap on the pre-parsed schedule sheet handed to the model when building the
# itinerary (sized for the 8K-token default context of current local models).
MAX_FACTS_CHARS = [[[MAX_FACTS_CHARS|6000]]]

# One entry per ingested file: {"source": name, "facts": [schedule lines]}
_TRIP_FILES = []

# Lines that look schedule-relevant: times, dates, travel keywords…
_TIMEY_RE = re.compile(
    r"[0-9]{1,2}:[0-9]{2}"                                    # 14:05
    r"|\b[0-9]{1,2}\s?(?:am|pm)\b"                         # 2 pm
    r"|[0-9]{4}-[0-9]{2}-[0-9]{2}"                            # 2026-08-14
    r"|\b[0-9]{1,2}[/.][0-9]{1,2}[/.][0-9]{2,4}\b"          # 14/08/2026
    r"|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+[0-9]{1,2}\b"
    r"|\b[0-9]{1,2}(?:st|nd|rd|th)?\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b"
    r"|\b(?:mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)day\b"
    r"|check[- ]?in|check[- ]?out|departure|departs|arrival|arrives|boarding"
    r"|confirmation|booking|reservation|pick[- ]?up",
    re.I,
)
# …plus flight numbers (case-sensitive so plain words never match).
_FLIGHT_RE = re.compile(r"\b[A-Z]{2}[0-9]{2,4}\b")


def _extract_pdf_text(pdf_bytes):
    """(JS-callable) Extract plain text from a PDF's raw bytes via pypdf."""
    import io
    from pypdf import PdfReader

    reader = PdfReader(io.BytesIO(pdf_bytes))
    return "\n\n".join((page.extract_text() or "") for page in reader.pages)


def _scan_facts(text):
    """Pull the schedule-relevant lines out of one file's text."""
    facts = []
    for raw in text.splitlines():
        line = " ".join(raw.split())
        if len(line) < 4:
            continue
        if _TIMEY_RE.search(line) or _FLIGHT_RE.search(line):
            facts.append(line[:200])
    return facts


async def _ingest_trip_file(source, text):
    """(JS-callable) Index one booking/note file and scan its schedule lines.

    Returns JSON {"chunks": int, "facts": int} for the file list UI.
    """
    chunks = await agentop_rag.add_document(source, text)
    facts = _scan_facts(text)
    _TRIP_FILES.append({"source": source, "facts": facts})
    return json.dumps({"chunks": chunks, "facts": len(facts)})


def _facts_sheet():
    lines = []
    for f in _TRIP_FILES:
        lines.append(f"FILE: {f['source']}")
        lines.extend(f"  - {fact}" for fact in (f["facts"] or ["(no dated lines found)"]))
    return "\n".join(lines)[:MAX_FACTS_CHARS]


async def _build_itinerary():
    """(JS-callable) Draft the day-by-day itinerary + gap check via wllama."""
    if not _TRIP_FILES:
        return "Drop your booking PDFs and notes first, then build the itinerary."
    prompt = (
        "Below are schedule lines extracted from one traveller's booking files "
        "(flights, hotels, notes). Reconstruct the trip as a clean itinerary.\n"
        "Format exactly:\n"
        "## Itinerary\n"
        "One '### <day, date>' section per day, in order; under each, one "
        "chronological bullet per event: time — what and where, with flight "
        "numbers or booking references when given.\n\n"
        "## Gaps & watch-outs\n"
        "Bullets for logistical problems you can see: arrival vs check-in "
        "mismatches (e.g. landing at 14:00 but check-in from 16:00), "
        "connections under 90 minutes, nights with no lodging, missing "
        "transport between cities. Write 'None spotted.' if it all lines up.\n\n"
        "Use ONLY the lines below. If a date or time is ambiguous, say so "
        "rather than guessing.\n\n"
        f"SCHEDULE LINES:\n{_facts_sheet()}"
    )
    # TEMPLATE_SYSTEM_PROMPT only exists as a JS global; the query bridge falls
    # back to it when custom_prompt is empty (same idiom as the base template).
    return await process_user_query_wllama(
        prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )


async def process_user_query(query):
    """Follow-up Q&A grounded in the uploaded trip documents (RAG).

    Overrides the default router so retrieval is deterministic (small local
    models are unreliable at deciding to call a search tool themselves).
    """
    if not _TRIP_FILES:
        return (
            "I don't have any trip files yet — drop your flight PDFs, hotel "
            "emails, and notes above and I'll piece the trip together."
        )
    hits = await agentop_rag.search(query, RAG_TOP_K)
    excerpts = "\n\n".join(
        f"[{i + 1}] (from {h['source']}) {h['text']}" for i, h in enumerate(hits)
    ) or "(nothing relevant found)"
    grounded_prompt = (
        "Answer the traveller's QUESTION using ONLY the TRIP DOCUMENT "
        "EXCERPTS below. Use exact dates, times, flight numbers, and booking "
        "references from the excerpts, and cite them like [1]. If the answer "
        "is not in the excerpts, say you could not find it in the trip files.\n\n"
        f"TRIP DOCUMENT EXCERPTS:\n{excerpts}\n\nQUESTION: {query}"
    )
    return await process_user_query_wllama(
        grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )