Ryanhub - file viewer
filename: server/static/app.js
branch: main
back to repo
(function () {
  const $ = (id) => document.getElementById(id);

  const els = {
    statusPill: $("status-pill"),
    statusText: $("status-text"),
    modelLine: $("model-line"),
    chatLog: $("chat-log"),
    form: $("chat-form"),
    prompt: $("prompt"),
    send: $("send-btn"),
    widgets: $("widgets"),
    timeline: $("timeline"),
    summary: $("summary"),
  };

  function initSectionCollapse() {
    const sections = document.querySelectorAll(".section");
    for (const section of sections) {
      const head = section.querySelector(".panel-head");
      if (!head) continue;
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = "section-toggle";
      btn.setAttribute("aria-label", "toggle section");
      btn.setAttribute("aria-expanded", "true");
      btn.textContent = "▾";
      btn.addEventListener("click", () => {
        const collapsed = section.classList.toggle("collapsed");
        btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
        btn.textContent = collapsed ? "▸" : "▾";
      });
      head.appendChild(btn);
    }
  }

  function setBusy(on) {
    els.statusPill.classList.toggle("busy", on);
    document.title = on ? "assistant · …" : "assistant";
  }

  function appendTimeline(kind, text) {
    const li = document.createElement("li");
    const t = document.createElement("div");
    t.className = "t";
    t.textContent = kind;
    const body = document.createElement("div");
    body.className = "body";
    body.textContent = text;
    li.appendChild(t);
    li.appendChild(body);
    els.timeline.appendChild(li);
    li.scrollIntoView({ block: "nearest" });
  }

  function appendChat(role, who, text) {
    const wrap = document.createElement("div");
    wrap.className = "bubble " + role;
    const w = document.createElement("div");
    w.className = "who";
    w.textContent = who;
    const b = document.createElement("div");
    b.textContent = text;
    wrap.appendChild(w);
    wrap.appendChild(b);
    els.chatLog.appendChild(wrap);
    wrap.scrollIntoView({ block: "nearest" });
  }

  function clearUIChat() {
    els.chatLog.innerHTML = "";
    els.timeline.innerHTML = "";
    els.summary.textContent = "";
  }

  function appendAssistantThinking() {
    const wrap = document.createElement("div");
    wrap.className = "bubble assistant thinking";
    const w = document.createElement("div");
    w.className = "who";
    w.textContent = "assistant";
    const b = document.createElement("div");
    b.className = "body";
    b.textContent = "thinking";
    wrap.appendChild(w);
    wrap.appendChild(b);
    els.chatLog.appendChild(wrap);
    wrap.scrollIntoView({ block: "nearest" });
    return { wrap, textEl: b };
  }

  function eventBody(ev) {
    switch (ev.type) {
      case "phase":
        return ev.message || ev.detail || "";
      case "llm_request":
        return `round ${ev.round}`;
      case "llm_reply":
        return (
          `round ${ev.round} · pending tools: ${ev.tool_calls || 0}` +
          (ev.has_content && ev.preview ? `\n${ev.preview}` : "")
        );
      case "tool_call":
        return `${ev.tool}\nargs: ${ev.args || "{}"}`;
      case "tool_result":
        return `${ev.tool} · ${ev.ok ? "ok" : "error"}\n${ev.preview || ""}`;
      case "final":
        return "done";
      case "error":
        return ev.message || "";
      default:
        return JSON.stringify(ev);
    }
  }

  function eventTitle(ev) {
    switch (ev.type) {
      case "phase":
        return "prepare";
      case "llm_request":
        return "calling model";
      case "llm_reply":
        return "model reply";
      case "tool_call":
        return "tool call";
      case "tool_result":
        return "tool result";
      case "final":
        return "done";
      case "error":
        return "error";
      default:
        return ev.type || "event";
    }
  }

  function renderWidgets(status) {
    els.widgets.innerHTML = "";
    const widgets = [
      { type: "status", title: "overview" },
      { type: "automations", title: "automations" },
      { type: "tools", title: "tools" },
      { type: "memory_context", title: "memory" },
    ];

    for (const w of widgets) {
      const card = document.createElement("div");
      card.className = "widget-card";
      const title = document.createElement("div");
      title.className = "widget-title";
      title.textContent = w.title;
      card.appendChild(title);

      if (w.type === "status") {
        const lines = document.createElement("div");
        lines.className = "status-lines";
        lines.textContent =
          `status: ${status.status || "idle"}\ncurrent prompt: ${status.current_prompt || "-"}\n` +
          `uptime: ${status.uptime_seconds || 0}s`;
        card.appendChild(lines);
      } else if (w.type === "tools") {
        const table = document.createElement("table");
        table.className = "kv-table";
        table.innerHTML = "<thead><tr><th>Tool</th><th>Calls</th><th>avg</th></tr></thead>";
        const body = document.createElement("tbody");
        const rows = [];
        const llm = status.llm || {};
        rows.push({
          name: "llm",
          count: Number(llm.count || 0),
          avg_ms: Number(llm.avg_ms || 0),
        });
        for (const r of status.tools?.by_name || []) {
          rows.push(r);
        }
        for (let i = 0; i < Math.min(8, rows.length); i++) {
          const r = rows[i];
          const tr = document.createElement("tr");
          tr.innerHTML = `<td>${r.name}</td><td>${r.count}</td><td>${Number(r.avg_ms || 0).toFixed(1)}</td>`;
          body.appendChild(tr);
        }
        table.appendChild(body);
        card.appendChild(table);
      } else if (w.type === "automations") {
        const list = document.createElement("div");
        list.className = "status-lines";
        const autos = status.automations || [];
        list.textContent = autos.length
          ? autos
              .map((a) => `- ${a.name}\n  next: ${a.next_run || "unknown"}\n  last: ${a.last_run || "-"}`)
              .join("\n\n")
          : "no automations configured.";
        card.appendChild(list);
      } else if (w.type === "memory_context") {
        const s = status.memory_store || {};
        const h = status.history || {};
        const c = status.context_consumption || {};
        const line = document.createElement("div");
        line.className = "status-lines";
        line.textContent =
          `context used: ${Number(c.used_chars || 0).toLocaleString()} chars\n` +
          `chat session: ${Number(h.count || 0)} msgs, ${Number(h.total_chars || 0).toLocaleString()} chars\n` +
          `long memory: ${Number(s.long_count || 0)} items, ${Number(s.long_chars || 0).toLocaleString()} chars`;
        card.appendChild(line);
      }

      els.widgets.appendChild(card);
    }
  }

  function handleSSEEvent(ev) {
    appendTimeline(eventTitle(ev), eventBody(ev));
    if (ev.type === "final") {
      els.summary.textContent = ev.text || "";
    }
    if (ev.type === "error") {
      const msg = ev.message || "Unknown error";
      els.summary.textContent = (els.summary.textContent ? `${els.summary.textContent}\n\n` : "") + msg;
      appendChat("assistant", "assistant", `error: ${msg}`);
    }
  }

  async function fetchStatusAndUpdate() {
    const res = await fetch("/api/status", { cache: "no-store" });
    if (!res.ok) return;
    const st = await res.json();
    const working = st.status === "working";
    setBusy(working);
    els.statusText.textContent = working ? "working..." : "ready";
    const model = (st.model || "").trim();
    els.modelLine.textContent = model ? `model: ${model}` : "model: -";
    renderWidgets(st);
  }

  function startStatusPolling() {
    fetchStatusAndUpdate().catch(() => {});
    window.__statusPoll = setInterval(() => {
      fetchStatusAndUpdate().catch(() => {});
    }, 2000);
  }

  async function runPrompt(text) {
    let thinking = null;
    let thinkingInterval = null;
    const stopThinking = () => {
      if (thinkingInterval) clearInterval(thinkingInterval);
      if (thinking?.wrap && thinking.wrap.parentNode) thinking.wrap.parentNode.removeChild(thinking.wrap);
      thinking = null;
      thinkingInterval = null;
    };

    setBusy(true);
    els.statusText.textContent = "working...";
    els.summary.textContent = "";
    appendChat("user", "you", text);
    thinking = appendAssistantThinking();
    let dots = 0;
    thinkingInterval = setInterval(() => {
      dots = (dots + 1) % 4;
      thinking.textEl.textContent = "thinking" + ".".repeat(dots);
    }, 450);

    const res = await fetch("/ask/stream", {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
      body: JSON.stringify({ prompt: text }),
    });

    if (!res.ok || !res.body) {
      const err = await res.text();
      stopThinking();
      appendTimeline("error", err || res.statusText);
      setBusy(false);
      els.statusText.textContent = "ready";
      return;
    }

    const reader = res.body.getReader();
    const dec = new TextDecoder();
    let buf = "";
    let finalText = "";
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += dec.decode(value, { stream: true });
      const parts = buf.split("\n\n");
      buf = parts.pop() || "";
      for (const block of parts) {
        const line = block.trim();
        if (!line.startsWith("data:")) continue;
        try {
          const ev = JSON.parse(line.slice(5).trim());
          handleSSEEvent(ev);
          if (ev.type === "final") finalText = ev.text || "";
        } catch {}
      }
    }

    stopThinking();
    if (finalText) appendChat("assistant", "assistant", finalText);
    setBusy(false);
    els.statusText.textContent = "ready";
  }

  els.form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const text = els.prompt.value.trim();
    if (!text) return;
    if (text === "/clear") {
      els.prompt.value = "";
      clearUIChat();
      return;
    }
    els.prompt.value = "";
    els.send.disabled = true;
    try {
      await runPrompt(text);
    } catch (err) {
      appendTimeline("error", String(err));
    } finally {
      els.send.disabled = false;
      els.prompt.focus();
    }
  });

  els.prompt.addEventListener("keydown", (e) => {
    if (e.key !== "Enter" || e.shiftKey) return;
    e.preventDefault();
    if (els.send.disabled) return;
    if (els.form.requestSubmit) {
      els.form.requestSubmit();
      return;
    }
    els.form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
  });

  els.statusText.textContent = "ready";
  els.modelLine.textContent = "model: -";
  initSectionCollapse();
  startStatusPolling();
})();