tandem-server 0.4.20

HTTP server for Tandem engine APIs
Documentation
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Tandem Admin</title>
    <style>
      :root {
        --bg0: #080d19;
        --bg1: #132747;
        --bg2: #0f3d46;
        --glass: rgba(20, 31, 56, 0.62);
        --glass-border: rgba(132, 163, 225, 0.26);
        --text: #eef5ff;
        --muted: #98abcf;
        --ok: #54d996;
        --warn: #f3be68;
        --err: #ff6d7f;
        --accent: #69b1ff;
      }
      * { box-sizing: border-box; }
      @keyframes pulse { 0%,100% { opacity: .55; transform: scale(1);} 50% { opacity: 1; transform: scale(1.12);} }
      @keyframes shimmer { 0% { background-position: -260px 0;} 100% { background-position: 260px 0;} }
      body {
        margin: 0; color: var(--text); font-family: "Space Grotesk", "Avenir Next", "Segoe UI", sans-serif;
        background:
          radial-gradient(800px 500px at 0% -10%, var(--bg1) 0%, transparent 60%),
          radial-gradient(700px 420px at 100% 0%, var(--bg2) 0%, transparent 65%),
          var(--bg0);
      }
      .wrap { max-width: 1200px; margin: 20px auto; padding: 0 14px; }
      .card {
        border: 1px solid var(--glass-border); border-radius: 16px; background: var(--glass); backdrop-filter: blur(14px);
        box-shadow: 0 12px 38px rgba(0, 0, 0, .35); transition: transform .18s ease, border-color .18s ease;
      }
      .card:hover { transform: translateY(-2px); border-color: rgba(132, 163, 225, .45); }
      .top { display: grid; gap: 10px; grid-template-columns: 1fr auto auto; padding: 12px; }
      .tabs { display: flex; gap: 8px; margin: 12px 0; }
      button, input, textarea, select {
        font: inherit; border-radius: 10px; border: 1px solid #31466f; background: #0f1830; color: var(--text); padding: 9px 11px;
      }
      button { cursor: pointer; transition: transform .16s ease, filter .16s ease; }
      button:hover { transform: translateY(-1px); filter: brightness(1.05); }
      .btn-primary { border: none; background: linear-gradient(90deg, #7cbcff, #54d996); color: #03151d; font-weight: 700; }
      .btn-danger { border-color: #6a2a37; background: #2a1521; color: #ffbcc8; }
      .tab.active { border-color: #5e90db; background: #15264a; }
      .grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
      .pane { display: none; }
      .pane.active { display: block; animation: in .2s ease; }
      @keyframes in { from { opacity: 0; transform: translateY(5px);} to { opacity: 1; transform: translateY(0);} }
      .status-dot { width: 9px; height: 9px; border-radius: 999px; display: inline-block; margin-right: 6px; }
      .status-dot.live { background: var(--ok); animation: pulse 1.5s ease-in-out infinite; }
      .status-dot.off { background: #68748f; }
      .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
      .muted { color: var(--muted); }
      .list { display: grid; gap: 8px; }
      .item { padding: 10px; border: 1px solid #304468; border-radius: 12px; background: rgba(13, 22, 42, 0.75); }
      .split { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
      .skeleton { height: 54px; border-radius: 12px; background: linear-gradient(90deg, #1a2744 8%, #2a3d66 38%, #1a2744 62%); background-size: 400px 100%; animation: shimmer 1.2s linear infinite; }
      .mono { font-family: "JetBrains Mono", "Cascadia Mono", monospace; font-size: 12px; }
      .hidden { display: none !important; }
      #tokenModal {
        position: fixed; inset: 0; display: grid; place-items: center; background: rgba(2, 4, 9, 0.72);
      }
      #tokenModal .card { width: min(540px, 94vw); padding: 16px; }
      @media (max-width: 920px) { .grid, .split, .top { grid-template-columns: 1fr; } }
      @media (prefers-reduced-motion: reduce) {
        * { animation: none !important; transition: none !important; }
      }
    </style>
  </head>
  <body>
    <div id="tokenModal">
      <div class="card">
        <h2 style="margin:0 0 8px">Tandem Admin Token</h2>
        <div class="muted" style="margin-bottom:10px">Token stays in memory for this tab only.</div>
        <input id="tokenInput" placeholder="tk_..." />
        <div class="row" style="margin-top:10px">
          <button id="tokenSubmit" class="btn-primary">Unlock</button>
          <span id="tokenErr" class="muted"></span>
        </div>
      </div>
    </div>
    <div class="wrap">
      <div class="card top">
        <div class="row"><strong>Tandem Headless Admin</strong> <span id="liveBadge" class="muted">offline</span></div>
        <button id="reloadBtn" class="btn-primary">Reload Config</button>
        <button id="signoutBtn">Sign Out</button>
      </div>
      <div class="tabs">
        <button class="tab active" data-tab="connections">Connections</button>
        <button class="tab" data-tab="sessions">Sessions</button>
        <button class="tab" data-tab="memory">Memory</button>
        <button class="tab" data-tab="settings">Settings</button>
      </div>

      <section id="connections" class="pane active">
        <div id="connList" class="grid"></div>
      </section>

      <section id="sessions" class="pane">
        <div class="split">
          <div class="card" style="padding:10px">
            <div class="row"><input id="sessionSearch" placeholder="Search sessions..." /><button id="sessionRefresh">Refresh</button></div>
            <div id="sessionList" class="list" style="margin-top:10px"></div>
          </div>
          <div class="card" style="padding:10px">
            <div class="muted">Session messages</div>
            <div id="sessionMsgs" class="list mono" style="margin-top:10px; max-height:540px; overflow:auto"></div>
          </div>
        </div>
      </section>

      <section id="memory" class="pane">
        <div class="card" style="padding:10px">
          <div class="row"><input id="memorySearch" placeholder="Filter memory..." /><button id="memoryRefresh">Refresh</button></div>
          <div id="memoryList" class="list" style="margin-top:10px"></div>
        </div>
      </section>

      <section id="settings" class="pane">
        <div class="card" style="padding:10px">
          <div class="muted">Providers</div>
          <pre id="providers" class="mono" style="white-space:pre-wrap"></pre>
        </div>
      </section>
    </div>

    <script>
      const st = { token: "", sseAbort: null, pollTimer: null, selectedSession: "" };
      const $ = (id) => document.getElementById(id);
      const tabs = [...document.querySelectorAll(".tab")];

      function authHeaders(jsonBody) {
        const h = { "X-Tandem-Token": st.token };
        if (jsonBody) h["content-type"] = "application/json";
        return h;
      }
      async function api(path, opts = {}) {
        const res = await fetch(path, { ...opts, headers: { ...authHeaders(!!opts.body), ...(opts.headers || {}) } });
        if (!res.ok) throw new Error(`${path} failed (${res.status})`);
        if (res.status === 204) return null;
        const t = await res.text();
        return t ? JSON.parse(t) : null;
      }
      function setLive(online) {
        $("liveBadge").textContent = online ? "live" : "offline";
        $("liveBadge").style.color = online ? "var(--ok)" : "var(--muted)";
      }

      async function boot() {
        await renderConnections();
        await renderSessions();
        await renderMemory();
        await renderSettings();
        startRealtime();
      }

      function channelCard(name, data) {
        const pretty = name[0].toUpperCase() + name.slice(1);
        const dot = data.connected ? "live" : "off";
        const el = document.createElement("div");
        el.className = "card";
        el.style.padding = "10px";
        const enabled = !!data.enabled;
        el.innerHTML = `
          <div class="row"><span class="status-dot ${dot}"></span><strong>${pretty}</strong> <span class="muted">${enabled ? "configured" : "not configured"}</span></div>
          <div class="muted" style="margin-top:6px">active sessions: <span data-ctr="${name}">${data.active_sessions || 0}</span></div>
          <div class="row" style="margin-top:10px">
            <input placeholder="bot token" data-field="token" />
            <button data-action="save" class="btn-primary">${enabled ? "Update" : "Enable"}</button>
            <button data-action="disable" class="btn-danger">Disable</button>
          </div>`;
        const tokenInput = el.querySelector("input");
        el.querySelector("[data-action='save']").onclick = async () => {
          const body = { bot_token: tokenInput.value.trim(), allowed_users: ["*"] };
          if (name === "slack") body.channel_id = "C00000000";
          try {
            await api(`/channels/${name}`, { method: "PUT", body: JSON.stringify(body) });
            await renderConnections();
          } catch (e) { alert(String(e.message || e)); }
        };
        el.querySelector("[data-action='disable']").onclick = async () => {
          try { await api(`/channels/${name}`, { method: "DELETE" }); await renderConnections(); }
          catch (e) { alert(String(e.message || e)); }
        };
        return el;
      }

      async function renderConnections() {
        const root = $("connList");
        root.innerHTML = `<div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div>`;
        const data = await api("/channels/status");
        root.innerHTML = "";
        ["telegram", "discord", "slack"].forEach((name) => root.appendChild(channelCard(name, data[name] || {})));
      }

      async function renderSessions() {
        const q = $("sessionSearch").value.trim();
        const list = await api(`/session?page=1&page_size=30${q ? `&q=${encodeURIComponent(q)}` : ""}`);
        const root = $("sessionList");
        root.innerHTML = "";
        (list || []).forEach((s) => {
          const item = document.createElement("div");
          item.className = "item";
          item.innerHTML = `<div><strong>${s.title || "Untitled"}</strong></div><div class="muted mono">${s.id}</div>`;
          item.onclick = async () => {
            st.selectedSession = s.id;
            const msgs = await api(`/session/${encodeURIComponent(s.id)}/message`);
            const msgRoot = $("sessionMsgs");
            msgRoot.innerHTML = "";
            (msgs || []).slice(-30).forEach((m) => {
              const d = document.createElement("div");
              d.className = "item";
              const role = (m.role || m.info?.role || "unknown").toString();
              d.textContent = `[${role}] ${(m.parts || []).map((p) => p.text || p.content || "").join(" ")}`;
              msgRoot.appendChild(d);
            });
          };
          root.appendChild(item);
        });
      }

      async function renderMemory() {
        const q = $("memorySearch").value.trim().toLowerCase();
        const data = await api("/memory?limit=100");
        const root = $("memoryList");
        root.innerHTML = "";
        const rows = (data?.items || []).filter((r) => !q || JSON.stringify(r).toLowerCase().includes(q));
        rows.forEach((row) => {
          const item = document.createElement("div");
          item.className = "item";
          item.innerHTML = `<div class="row"><strong>${row.id}</strong><button class="btn-danger">Delete</button></div><div class="mono muted">${(row.content || "").slice(0, 220)}</div>`;
          item.querySelector("button").onclick = async () => {
            if (!confirm(`Delete memory ${row.id}?`)) return;
            await api(`/memory/${encodeURIComponent(row.id)}`, { method: "DELETE" });
            await renderMemory();
          };
          root.appendChild(item);
        });
      }

      async function renderSettings() {
        const providers = await api("/config/providers");
        $("providers").textContent = JSON.stringify(providers, null, 2);
      }

      function startPollingFallback() {
        stopPollingFallback();
        st.pollTimer = setInterval(async () => {
          try {
            await Promise.all([renderConnections(), renderSessions(), renderMemory()]);
          } catch (_) {}
        }, 5000);
      }
      function stopPollingFallback() {
        if (st.pollTimer) { clearInterval(st.pollTimer); st.pollTimer = null; }
      }

      async function startRealtime() {
        if (st.sseAbort) st.sseAbort.abort();
        const abort = new AbortController();
        st.sseAbort = abort;
        try {
          const res = await fetch("/event", { headers: { ...authHeaders(false), Accept: "text/event-stream" }, signal: abort.signal });
          if (!res.ok || !res.body) throw new Error("sse unavailable");
          setLive(true);
          stopPollingFallback();
          const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = "";
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            buf += decoder.decode(value, { stream: true });
            const blocks = buf.replace(/\r\n/g, "\n").split("\n\n");
            buf = blocks.pop() || "";
            for (const b of blocks) {
              const data = b.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n");
              if (!data || data === "[DONE]") continue;
              const evt = JSON.parse(data);
              const t = evt.type || evt.event_type || "";
              if (t.startsWith("channel.") || t.startsWith("session.") || t.startsWith("memory.")) {
                if (t.startsWith("channel.")) renderConnections();
                if (t.startsWith("session.")) renderSessions();
                if (t.startsWith("memory.")) renderMemory();
              }
            }
          }
        } catch (_) {
          setLive(false);
          startPollingFallback();
        }
      }

      $("reloadBtn").onclick = async () => { await api("/admin/reload-config", { method: "POST", body: "{}" }); await boot(); };
      $("signoutBtn").onclick = () => location.reload();
      $("sessionRefresh").onclick = renderSessions;
      $("memoryRefresh").onclick = renderMemory;
      $("sessionSearch").oninput = () => renderSessions();
      $("memorySearch").oninput = () => renderMemory();
      tabs.forEach((t) => t.onclick = () => {
        tabs.forEach((x) => x.classList.remove("active"));
        t.classList.add("active");
        document.querySelectorAll(".pane").forEach((p) => p.classList.remove("active"));
        document.getElementById(t.dataset.tab).classList.add("active");
      });
      $("tokenSubmit").onclick = async () => {
        st.token = $("tokenInput").value.trim();
        try {
          await api("/global/health");
          $("tokenModal").classList.add("hidden");
          await boot();
        } catch {
          $("tokenErr").textContent = "Invalid token or unavailable server.";
        }
      };
    </script>
  </body>
</html>