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(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)}
`; }).join("\n"); function inline(s) { return escapeHtml(s) .replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1')
.replace(/\n/g, "