Agent Template

Semantic Search

Paste or upload text and search it by meaning, not keywords — instant results in your browser on a 25 MB model, no LLM or big download needed.

search embeddings semantic retrieval privacy
ozzo Jul 10, 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

Semantic Search 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 search embeddings semantic retrieval privacy
Template Preview

Template Metadata

Slug
semantic-search
Created By
ozzo
Created
Jul 10, 2026
Usage Count
1

Tags

search embeddings semantic retrieval privacy

Code Statistics

HTML Lines
38
CSS Lines
31
JS Lines
75
Python Lines
33

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="ss">
    <header class="ss__head">
      <h1>{{ agent_name }}</h1>
      <p class="ss__sub">{{ description }}</p>
    </header>

    <section class="ss__ingest">
      <textarea id="ss-text" class="ss__text"
                placeholder="Paste text to search over (notes, docs, an FAQ…)"></textarea>
      <div class="ss__ingest-row">
        <button id="ss-index" type="button" class="ss__btn">Index text</button>
        <label class="ss__file" for="ss-file">or upload a .txt / .md file
          <input id="ss-file" type="file" accept=".txt,.md,.markdown" multiple hidden>
        </label>
      </div>
      <div id="ss-status" class="ss__status" role="status"></div>
    </section>

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

    <form id="ss-form" class="ss__form" autocomplete="off">
      <input id="ss-input" type="text"
             placeholder="Search your text semantically…" required disabled>
      <button id="ss-search" type="submit" disabled>Search</button>
    </form>
  </main>
  <script>{{ js_code }}</script>
</body>
</html>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
       background: #f5f8f7; color: #16211d; }
.ss { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.ss__head h1 { font-size: 1.6rem; margin: 0 0 4px; }
.ss__sub { margin: 0 0 20px; color: #56655f; }
.ss__text { width: 100%; min-height: 110px; padding: 12px 14px; border-radius: 12px;
            border: 1px solid #c6d6d0; font: inherit; resize: vertical; background: #fff;
            color: #16211d; }
.ss__ingest-row { display: flex; align-items: center; gap: 14px; margin: 10px 0 0; }
.ss__btn { padding: 9px 16px; border: 0; border-radius: 10px; background: #0f9d78;
           color: #fff; font-weight: 600; cursor: pointer; }
.ss__file { font-size: .9rem; color: #0f9d78; cursor: pointer; }
.ss__status { font-size: .85rem; color: #56655f; margin: 10px 2px; min-height: 1.2em; }
.ss__results { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 16px; }
.ss__hit { padding: 10px 14px; border-radius: 12px; background: #fff;
           border: 1px solid #e0eae6; white-space: pre-wrap; line-height: 1.5; }
.ss__hit b { color: #0f9d78; }
.ss__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
            padding: 12px 16px; background: #fff; border-top: 1px solid #e0eae6; }
.ss__form input { flex: 1; padding: 12px 14px; border: 1px solid #cbd8d3;
                  border-radius: 10px; font-size: 1rem; }
.ss__form button { padding: 0 20px; border: 0; border-radius: 10px; background: #0f9d78;
                   color: #fff; font-weight: 600; cursor: pointer; }
.ss__form button:disabled { background: #a9bcb6; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
  body { background: #121a17; color: #e6efeb; }
  .ss__text, .ss__hit, .ss__form { background: #1b241f; border-color: #2c362f; color: #e6efeb; }
  .ss__form input { background: #121a17; color: #e6efeb; border-color: #2c362f; }
}
// Semantic Search UI wiring. The embeddings + RAG store are injected by the
// generator; this connects the index + search controls to them. No chat model.
(function () {
  const statusEl = () => document.getElementById('ss-status');
  const results = () => document.getElementById('results-container');
  const input = () => document.getElementById('ss-input');
  const searchBtn = () => document.getElementById('ss-search');

  let pyReady = false;

  function setStatus(msg) { statusEl().textContent = msg; }
  function enableSearch(on) {
    input().disabled = !on;
    searchBtn().disabled = !on;
  }

  async function ingest(source, text) {
    if (!text.trim()) return;
    setStatus('Indexing "' + source + '"…');
    window.pyodide.globals.set('__ss_src', source);
    window.pyodide.globals.set('__ss_text', text);
    const n = await window.pyodide.runPythonAsync('await _ingest(__ss_src, __ss_text)');
    setStatus('Indexed ' + n + ' passages from "' + source + '". Search away!');
    enableSearch(true);
  }

  document.getElementById('ss-index').addEventListener('click', async () => {
    if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
    try { await ingest('pasted text', document.getElementById('ss-text').value); }
    catch (err) { setStatus('Could not index text: ' + err); }
  });

  document.getElementById('ss-file').addEventListener('change', async (e) => {
    if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
    for (const file of e.target.files) {
      try { await ingest(file.name, await file.text()); }
      catch (err) { setStatus('Could not index "' + file.name + '": ' + err); }
    }
  });

  document.getElementById('ss-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const q = input().value.trim();
    if (!q || !pyReady) return;
    enableSearch(false);
    try {
      window.pyodide.globals.set('__ss_q', q);
      const out = await window.pyodide.runPythonAsync('await process_user_query(__ss_q)');
      results().innerHTML = '';
      const heading = document.createElement('div');
      heading.className = 'ss__status';
      heading.textContent = 'Results for "' + q + '":';
      results().appendChild(heading);
      for (const block of out.split('\n\n')) {
        const el = document.createElement('div');
        el.className = 'ss__hit';
        el.textContent = block;
        results().appendChild(el);
      }
    } catch (err) {
      setStatus('Search failed: ' + err);
    } finally {
      enableSearch(true);
    }
  });

  // Pyodide is ready after initAgent(); the orchestrator fires this on document.
  document.addEventListener('pyodide-ready', async () => {
    // Start from an empty index each session — this is an ad-hoc "search what
    // you paste now" tool, and the RAG store is shared per origin.
    try { await window.AgentOpRAG.clear(); } catch (e) {}
    pyReady = true;
    setStatus('Ready. Paste or upload text to search over — no AI model needed.');
  });
})();
# How many passages to return per search.
SEARCH_TOP_K = [[[SEARCH_TOP_K|5]]]


async def _ingest(source, text):
    """(JS-callable) Index text as individual passages, one per non-empty line.

    Per-line indexing keeps each note / FAQ entry / list item its own searchable
    unit (rather than merging a short blob into one chunk), so ranking is
    meaningful. Underscore-prefixed so it is never exposed to the LLM as a tool.
    Returns the number of passages stored, as a string (for the UI).
    """
    passages = [line.strip() for line in text.splitlines() if line.strip()]
    total = 0
    for passage in passages:
        total += await agentop_rag.add_document(source, passage)
    return str(total)


async def process_user_query(query):
    """Return the most semantically similar passages — no LLM generation.

    Retrieval is deterministic and runs entirely on the embedding model, so this
    template never loads a chat model.
    """
    hits = await agentop_rag.search(query, SEARCH_TOP_K)
    if not hits:
        return "No matches yet — index some text first, then search."
    lines = []
    for i, h in enumerate(hits):
        score = round(float(h["score"]) * 100)
        lines.append(f"[{i + 1}] {score}% match · {h['source']}\n{h['text']}")
    return "\n\n".join(lines)