import { ansiToHtml } from "./ansi.js"; // ---------- state ---------- // Samma SPA för båda lägena: "local" (en helmd, token-login) och // "hub" (Pi5-hubben: konto-login + agentväljare). GET /api/mode avgör. let mode = "local"; let token = localStorage.getItem("helmd_token") || ""; let agents = []; let agent = localStorage.getItem("helmd_agent") || ""; let sessions = []; let current = null; // valt sessionsnamn let currentSeq = -1; let shares = []; let currentShare = null; let es = null; // EventSource let pollTimer = null; const $ = (id) => document.getElementById(id); // base är prefixet för allt agent-scopat (sessions, shares, config) const base = () => (mode === "hub" ? `/api/agents/${encodeURIComponent(agent)}` : "/api"); const hasAgent = () => mode !== "hub" || !!agent; // ---------- api ---------- async function api(path, opts = {}) { opts.headers = Object.assign({ Authorization: "Bearer " + token }, opts.headers); const r = await fetch(path, opts); if (r.status === 401) { showLogin(mode === "hub" ? "Inte inloggad." : "Ogiltig token."); throw new Error("401"); } if (!r.ok) { let msg = r.statusText; try { msg = (await r.json()).error || msg; } catch {} throw new Error(msg); } const ct = r.headers.get("content-type") || ""; return ct.includes("json") ? r.json() : r.text(); } const post = (p, body) => api(p, { method: "POST", body: JSON.stringify(body) }); // ---------- login ---------- function applyModeUI() { const hub = mode === "hub"; $("userInput").classList.toggle("hidden", !hub); $("tokenInput").placeholder = hub ? "lösenord" : "token"; $("loginHint").innerHTML = hub ? "Logga in med ditt hub-konto." : "Klistra in API-token från servern (helmd token)."; $("agentBar").classList.toggle("hidden", !hub); $("hubSettings").classList.toggle("hidden", !hub); } function showLogin(err = "") { stopLive(); $("login").classList.remove("hidden"); $("app").classList.add("hidden"); $("loginError").textContent = err; } async function tryConnect() { try { if (mode === "hub") await refreshAgents(); const h = await api("/api/health"); $("login").classList.add("hidden"); $("app").classList.remove("hidden"); $("verInfo").textContent = (mode === "hub" ? "hub " : "helmd ") + h.version; startLive(); if (mode === "hub") { if (!agent || !agents.some((a) => a.name === agent)) { const first = agents.find((a) => a.online) || agents[0]; agent = first ? first.name : ""; } renderAgentSelect(); if (agent) await switchAgent(agent, true); } else { await Promise.all([refreshSessions(), refreshShares(), loadConfig()]); if (!current && sessions.length) selectSession(sessions[0].name); } } catch (e) { if (e.message !== "401") showLogin("Kunde inte nå servern: " + e.message); } } $("tokenSave").onclick = async () => { if (mode === "hub") { try { const r = await fetch("/api/login", { method: "POST", body: JSON.stringify({ username: $("userInput").value.trim(), password: $("tokenInput").value }), }); if (!r.ok) { $("loginError").textContent = "Fel användarnamn eller lösenord."; return; } token = (await r.json()).token; } catch (e) { $("loginError").textContent = "Kunde inte nå hubben: " + e.message; return; } } else { token = $("tokenInput").value.trim(); } localStorage.setItem("helmd_token", token); $("tokenInput").value = ""; tryConnect(); }; $("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") $("tokenSave").click(); }); $("logoutBtn").onclick = async () => { if (mode === "hub") { try { await post("/api/logout", {}); } catch {} } localStorage.removeItem("helmd_token"); token = ""; showLogin(); }; // ---------- agenter (hub-läge) ---------- async function refreshAgents() { agents = (await api("/api/agents")) || []; agents.sort((a, b) => a.name.localeCompare(b.name)); renderAgentSelect(); } function renderAgentSelect() { const sel = $("agentSelect"); sel.innerHTML = ""; for (const a of agents) { const o = document.createElement("option"); o.value = a.name; o.textContent = (a.online ? "● " : "○ ") + a.name; if (a.name === agent) o.selected = true; sel.appendChild(o); } if (!agents.length) { const o = document.createElement("option"); o.textContent = "(inga agenter anslutna)"; o.disabled = true; sel.appendChild(o); } } async function switchAgent(name, force = false) { if (!force && name === agent) return; agent = name; localStorage.setItem("helmd_agent", agent); current = null; currentSeq = -1; currentShare = null; $("sessPane").classList.add("hidden"); $("noSess").classList.remove("hidden"); $("shareView").innerHTML = "Välj en delning i listan"; await Promise.all([refreshSessions(), refreshShares(), loadConfig().catch(() => {})]); if (!current && sessions.length) selectSession(sessions[0].name); } $("agentSelect").onchange = (e) => switchAgent(e.target.value); // ---------- tabs ---------- document.querySelectorAll(".tab").forEach((b) => { b.onclick = () => { document.querySelectorAll(".tab").forEach((x) => x.classList.toggle("active", x === b)); ["sessions", "shares", "settings"].forEach((t) => $("tab-" + t).classList.toggle("hidden", b.dataset.tab !== t)); if (b.dataset.tab === "shares") { $("shareBadge").classList.add("hidden"); $("shareBadge").textContent = ""; } }; }); // ---------- SSE + poll ---------- // I hub-läge har alla events fältet "agent" — vi bryr oss bara om den // valda agentens events, plus "agent"-events som uppdaterar väljaren. function startLive() { stopLive(); es = new EventSource("/api/events?token=" + encodeURIComponent(token)); es.onopen = () => $("connDot").classList.add("ok"); es.onerror = () => $("connDot").classList.remove("ok"); const mine = (ev) => mode !== "hub" || ev.agent === agent; es.addEventListener("screen", (e) => { const ev = JSON.parse(e.data); if (!mine(ev)) return; if (ev.session === current) refreshScreen(); refreshSessions(); }); es.addEventListener("question", (e) => { const ev = JSON.parse(e.data); if (mine(ev) && ev.session === current) renderQuestion(ev.data); }); es.addEventListener("session", (e) => { const ev = JSON.parse(e.data); if (mine(ev)) refreshSessions(); }); es.addEventListener("agent", () => refreshAgents()); es.addEventListener("share", (e) => { const ev = JSON.parse(e.data); if (!mine(ev)) return; refreshShares(); const b = $("shareBadge"); if ($("tab-shares").classList.contains("hidden")) { b.textContent = String(Number(b.textContent || 0) + 1); b.classList.remove("hidden"); } }); // fallback-poll om SSE tappas pollTimer = setInterval(() => { if (es && es.readyState === 2) { startLive(); return; } if (current) refreshScreen(); }, 5000); } function stopLive() { if (es) { es.close(); es = null; } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } $("connDot").classList.remove("ok"); } // ---------- sessioner ---------- async function refreshSessions() { if (!hasAgent()) { $("sessItems").innerHTML = ""; return; } sessions = (await api(base() + "/sessions")) || []; sessions.sort((a, b) => a.name.localeCompare(b.name)); const box = $("sessItems"); box.innerHTML = ""; for (const s of sessions) { const d = document.createElement("div"); d.className = "sess-item" + (s.name === current ? " active" : ""); d.innerHTML = ` ${s.name}${s.question ? '' : ""}`; d.onclick = () => selectSession(s.name); box.appendChild(d); } if (current) { const cur = sessions.find((s) => s.name === current); if (!cur) { current = null; $("sessPane").classList.add("hidden"); $("noSess").classList.remove("hidden"); } else if (cur.seq !== currentSeq) refreshScreen(); } } async function selectSession(name) { current = name; currentSeq = -1; $("noSess").classList.add("hidden"); $("sessPane").classList.remove("hidden"); $("sessTitle").textContent = name; const s = sessions.find((x) => x.name === name); renderQuestion(s && s.question); await refreshScreen(); refreshSessions(); } async function refreshScreen() { if (!current || !hasAgent()) return; try { const s = sessions.find((x) => x.name === current); if (s) currentSeq = s.seq; const txt = await api(`${base()}/sessions/${current}/screen?ansi=1`); const el = $("screen"); const stick = el.scrollTop + el.clientHeight >= el.scrollHeight - 30; el.innerHTML = ansiToHtml(txt); if (stick) el.scrollTop = el.scrollHeight; const st = await api(`${base()}/sessions/${current}`); renderQuestion(st.question); } catch {} } function renderQuestion(q) { const card = $("questionCard"); if (!q) { card.classList.add("hidden"); card.innerHTML = ""; return; } card.classList.remove("hidden"); card.innerHTML = `

❓ ${escapeHtml(q.text)}

`; const opts = card.querySelector(".opts"); q.options.forEach((o, i) => { const b = document.createElement("button"); b.textContent = (o.num ? o.num + ". " : "") + o.label; if (i === q.selected) b.classList.add("selected"); b.onclick = async () => { b.disabled = true; try { await post(`${base()}/sessions/${current}/answer`, { option: i + 1 }); } catch (e) { alert("Kunde inte svara: " + e.message); b.disabled = false; } }; opts.appendChild(b); }); } $("newSessBtn").onclick = () => $("newSessForm").classList.toggle("hidden"); $("newSessCancel").onclick = () => $("newSessForm").classList.add("hidden"); $("newSessForm").onsubmit = async (e) => { e.preventDefault(); if (!hasAgent()) { alert("Ingen agent vald."); return; } try { await post(base() + "/sessions", { name: $("newSessName").value.trim(), cmd: $("newSessCmd").value.trim(), cwd: $("newSessCwd").value.trim(), }); const name = $("newSessName").value.trim(); $("newSessForm").reset(); $("newSessForm").classList.add("hidden"); await refreshSessions(); selectSession(name || sessions[sessions.length - 1]?.name); } catch (err) { alert(err.message); } }; $("killBtn").onclick = async () => { if (!current || !confirm(`Döda sessionen ${current}?`)) return; await api(`${base()}/sessions/${current}`, { method: "DELETE" }); current = null; refreshSessions(); $("sessPane").classList.add("hidden"); $("noSess").classList.remove("hidden"); }; const sendKeys = (keys) => current && post(`${base()}/sessions/${current}/keys`, { keys }).catch(() => {}); $("keyEsc").onclick = () => sendKeys(["Escape"]); $("keyUp").onclick = () => sendKeys(["Up"]); $("keyDown").onclick = () => sendKeys(["Down"]); $("keyEnter").onclick = () => sendKeys(["Enter"]); $("keyCtrlC").onclick = () => sendKeys(["C-c"]); $("modeBtn").onclick = () => current && post(`${base()}/sessions/${current}/mode`, { action: "cycle" }).catch(() => {}); $("promptForm").onsubmit = async (e) => { e.preventDefault(); const t = $("promptText").value; if (!t.trim() || !current) return; try { await post(`${base()}/sessions/${current}/prompt`, { text: t }); $("promptText").value = ""; } catch (err) { alert("Kunde inte skicka: " + err.message); } }; $("promptText").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("promptForm").requestSubmit(); } }); // ---------- delningar ---------- async function refreshShares() { if (!hasAgent()) { $("shareItems").innerHTML = ""; return; } shares = (await api(base() + "/shares")) || []; const box = $("shareItems"); box.innerHTML = ""; for (const sh of shares) { const d = document.createElement("div"); d.className = "share-item" + (currentShare === sh.id ? " active" : ""); const when = new Date(sh.created).toLocaleString("sv-SE", { dateStyle: "short", timeStyle: "short" }); d.innerHTML = `
${escapeHtml(sh.name)}
${when}${sh.session ? " · " + escapeHtml(sh.session) : ""}${sh.note ? "
" + escapeHtml(sh.note) : ""}
`; d.querySelector(".del").onclick = async (e) => { e.stopPropagation(); await api(`${base()}/shares/${sh.id}`, { method: "DELETE" }); if (currentShare === sh.id) { currentShare = null; $("shareView").innerHTML = "Välj en delning i listan"; } refreshShares(); }; d.onclick = () => viewShare(sh); box.appendChild(d); } } async function viewShare(sh) { currentShare = sh.id; refreshShares(); const v = $("shareView"); v.classList.remove("empty"); const raw = `${base()}/shares/${sh.id}/raw?token=${encodeURIComponent(token)}`; const ext = sh.name.toLowerCase().split(".").pop(); if (["png", "jpg", "jpeg", "gif", "svg"].includes(ext)) { v.innerHTML = `${escapeHtml(sh.name)}`; } else if (["html", "htm", "pdf"].includes(ext)) { v.innerHTML = ``; } else if (ext === "md") { const txt = await api(`${base()}/shares/${sh.id}/raw`); v.innerHTML = `
${mdToHtml(txt)}
`; } else { const txt = await api(`${base()}/shares/${sh.id}/raw`); v.innerHTML = `
${escapeHtml(txt)}
`; } } // ---------- inställningar ---------- async function loadConfig() { if (!hasAgent()) return; const c = await api(base() + "/config"); $("cfgCmd").value = c.agent_cmd || ""; $("cfgArgs").value = (c.agent_args || []).join(" "); $("cfgWorkdir").value = c.workdir || ""; $("cfgPoll").value = c.poll_ms || 500; $("cfgNtfyOn").checked = !!c.ntfy?.enabled; $("cfgNtfyQ").checked = !!c.ntfy?.notify_on_question; $("cfgNtfyServer").value = c.ntfy?.server || ""; $("cfgNtfyTopic").value = c.ntfy?.topic || ""; window._ntfyAllowed = c.ntfy?.allowed_topics || ["agent-helm", "claude"]; } $("cfgForm").onsubmit = async (e) => { e.preventDefault(); try { await api(base() + "/config", { method: "PUT", body: JSON.stringify({ agent_cmd: $("cfgCmd").value.trim(), agent_args: $("cfgArgs").value.trim() ? $("cfgArgs").value.trim().split(/\s+/) : [], workdir: $("cfgWorkdir").value.trim(), poll_ms: Number($("cfgPoll").value), ntfy: { enabled: $("cfgNtfyOn").checked, notify_on_question: $("cfgNtfyQ").checked, server: $("cfgNtfyServer").value.trim(), topic: $("cfgNtfyTopic").value.trim(), allowed_topics: window._ntfyAllowed, }, }), }); $("cfgStatus").textContent = "Sparat ✓"; setTimeout(() => ($("cfgStatus").textContent = ""), 2500); } catch (err) { $("cfgStatus").textContent = "Fel: " + err.message; } }; // agent-nyckeln (hub-läge): visas på begäran för att koppla in nya agenter $("agentKeyBtn").onclick = async () => { try { const r = await api("/api/agentkey"); $("agentKeyOut").textContent = r.agent_key; } catch (e) { $("agentKeyOut").textContent = "Fel: " + e.message; } }; // ---------- hjälp ---------- function escapeHtml(s) { return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // Miniminimal markdown: rubriker, fet/kursiv, kod, listor, länkar, hr. function mdToHtml(md) { const blocks = md.split(/\n{2,}/); return blocks.map((b) => { if (/^```/.test(b)) return `
${escapeHtml(b.replace(/^```\w*\n?|```$/g, ""))}
`; if (/^#{1,6} /.test(b)) { const n = b.match(/^#+/)[0].length; return `${inline(b.replace(/^#+ /, ""))}`; } if (/^([-*] |\d+\. )/m.test(b)) { const items = b.split("\n").filter(Boolean).map((l) => `
  • ${inline(l.replace(/^([-*]|\d+\.) /, ""))}
  • `).join(""); return /^\d/.test(b) ? `
      ${items}
    ` : ``; } if (/^(-{3,}|_{3,}|\*{3,})$/.test(b.trim())) return "
    "; return `

    ${inline(b)}

    `; }).join("\n"); function inline(s) { return escapeHtml(s) .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1') .replace(/\n/g, "
    "); } } // ---------- start ---------- (async () => { try { const m = await fetch("/api/mode"); if (m.ok) mode = (await m.json()).mode || "local"; } catch {} applyModeUI(); if (token) tryConnect(); else showLogin(); })();