From 12ffcaa2bdc0d0e025c3b9fe3261abd715e5da2c Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 22:17:12 +0200 Subject: [PATCH] =?UTF-8?q?helmd:=20inb=C3=A4ddad=20webbklient=20(vanilla?= =?UTF-8?q?=20SPA)=20+=20Docker-image=20f=C3=B6r=20Pi5=20(LAN-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/: token-login, sessionslista, ANSI-skärmvy (SSE+poll), promptfält, frågekort, tangentrad, delningsflik (bild/HTML-sandbox/markdown), inställningar - go:embed via webfs.go — servern förblir en enda binär, strikt CSP - browser-verifierad mot riktig agy (fråga -> kort -> svar -> kört) - Dockerfile (alpine + tmux/bash) + helmd-image.yaml -> localhost:5000/helmd - integrationstest utökat till 29 gröna Co-Authored-By: Claude Fable 5 --- .gitea/workflows/helmd-image.yaml | 32 +++ doc/plan.md | 21 +- helmd/.dockerignore | 4 + helmd/Dockerfile | 15 ++ helmd/README.md | 19 ++ helmd/api.go | 4 +- helmd/scripts/integration-test.sh | 7 + helmd/web/ansi.js | 78 +++++++ helmd/web/app.js | 335 ++++++++++++++++++++++++++++++ helmd/web/index.html | 110 ++++++++++ helmd/web/manifest.json | 17 ++ helmd/web/style.css | 165 +++++++++++++++ helmd/webfs.go | 29 +++ 13 files changed, 830 insertions(+), 6 deletions(-) create mode 100644 .gitea/workflows/helmd-image.yaml create mode 100644 helmd/.dockerignore create mode 100644 helmd/Dockerfile create mode 100644 helmd/web/ansi.js create mode 100644 helmd/web/app.js create mode 100644 helmd/web/index.html create mode 100644 helmd/web/manifest.json create mode 100644 helmd/web/style.css create mode 100644 helmd/webfs.go diff --git a/.gitea/workflows/helmd-image.yaml b/.gitea/workflows/helmd-image.yaml new file mode 100644 index 0000000..7eac204 --- /dev/null +++ b/.gitea/workflows/helmd-image.yaml @@ -0,0 +1,32 @@ +name: helmd-image + +# Bygger helmd-imagen (arm64, nativt på Pi-runnern) och pushar till den +# lokala registryn. Deployas som stack "helmd" i Dockge (LAN-only, +# ingen NPM-vhost). +on: + push: + branches: [master, main] + paths: + - "helmd/**" + - ".gitea/workflows/helmd-image.yaml" + workflow_dispatch: {} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: | + docker build \ + --build-arg "VERSION=latest-${GITHUB_SHA::7}" \ + -t localhost:5000/helmd:latest \ + -t "localhost:5000/helmd:${GITHUB_SHA::12}" \ + helmd/ + + - name: Push image + run: | + docker push localhost:5000/helmd:latest + docker push "localhost:5000/helmd:${GITHUB_SHA::12}" diff --git a/doc/plan.md b/doc/plan.md index 47c2908..39afbf2 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -41,11 +41,22 @@ webb-PWA (LAN/tunnel) ─────┼──► helmd :8788 (Go, REST+SSE, tok (event-pushar + `POST /api/notify`, topics `agent-helm`/`claude`). - Isolerat integrationstest i Docker (26 tester), CI-release `helmd-latest` (x64 + arm64). -2. ⬜ **Webbklient v2** (`packages/web`): porta till helmd-API:t. - - Sessionslista, skärmvy (poll + SSE), promptfält, frågekort med - knappar, läges-/inställningsvy. - - Delningsflik: html/md renderas, png/jpg visas, md till html. - - Uppdaterad look. Serveras statiskt av helmd (embed) eller nginx. +2. ✅ **Webbklient v2** (`helmd/web/`): **ramverksfri** ES-modul-SPA, + inbäddad i binären med `go:embed` — hela v2 är fortfarande EN fil, + ingen Node-toolchain i CI. (Beslut: vanilla i stället för Vue-port — + UI-ytan är liten, eslint/byggkedjan försvinner, agenter underhåller + koden lättare.) + - Token-login (localStorage), sessionslista med status/❓-badge, + ANSI-färgad skärmvy (SSE + poll-fallback), promptfält, + **frågekort med knappar**, tangent-verktygsrad (Esc/pilar/Enter/ + ^C/Läge), delningsflik (bilder, sandboxad HTML-iframe, egen + md-renderare), inställningsvy mot `PUT /api/config`. + - Browser-verifierad mot riktig agy: prompt → godkännandefråga → + svar via kort → kommandot kört; delningar renderade. + - Deploy på Pi5: docker-stack `helmd` (arm64-image via + `.gitea/workflows/helmd-image.yaml` → `localhost:5000/helmd`), + **LAN-only — ingen NPM-vhost**; sessioner kör bash/agenter i + containern. 3. ⬜ **Android-klient**: nytt projekt eller Capacitor-port av PWA:n. - Egen SSH-tunnel (t.ex. sshj/JSch) → Pi5 → helmd; token + värdprofil i appen; push via ntfy-appen eller UnifiedPush. diff --git a/helmd/.dockerignore b/helmd/.dockerignore new file mode 100644 index 0000000..4841b63 --- /dev/null +++ b/helmd/.dockerignore @@ -0,0 +1,4 @@ +build/ +scripts/ +testdata/ +README.md diff --git a/helmd/Dockerfile b/helmd/Dockerfile new file mode 100644 index 0000000..8302b3f --- /dev/null +++ b/helmd/Dockerfile @@ -0,0 +1,15 @@ +# helmd-image för Pi5 (byggs nativt arm64 på Gitea-runnern). +# Innehåller tmux + bash så att sessioner kan köras direkt i containern. +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY . . +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o /helmd . + +FROM alpine:3.21 +RUN apk add --no-cache tmux bash curl ca-certificates coreutils +COPY --from=build /helmd /usr/local/bin/helmd +ENV HELMD_CONFIG=/data/config.json +VOLUME /data +EXPOSE 8788 +CMD ["helmd", "serve", "--listen", "0.0.0.0:8788"] diff --git a/helmd/README.md b/helmd/README.md index c121520..8ca0444 100644 --- a/helmd/README.md +++ b/helmd/README.md @@ -107,6 +107,25 @@ kommandogodkännande) och mot mock i integrationstestet. } ``` +## Webbklienten + +Inbäddad i binären (`web/` + `go:embed`) och serveras på `/` — surfa +till servern (t.ex. `http://127.0.0.1:8788` genom en SSH-tunnel) och +klistra in din token. Ramverksfri (ren ES-moduler): sessionslista, +ANSI-färgad skärmvy (SSE + poll), promptfält, frågekort med knappar, +tangent-verktygsrad, delningsflik (bilder, sandboxad HTML, markdown) +och inställningar. Statiska filer kräver ingen auth (koden är publik); +allt data går genom det token-skyddade API:et. + +## Docker på Pi5 (LAN-only) + +`Dockerfile` bygger en arm64-image med tmux+bash via +`.gitea/workflows/helmd-image.yaml` → `localhost:5000/helmd:latest`. +Stack: `/srv/dockge-staks/helmd/` med host-port **8788** och volym +`/srv/docker/helmd → /data`. **Ingen NPM-vhost** — nås bara på LAN +(`http://192.168.0.19:8788`) eller via SSH-tunnel utifrån. Sessionerna +kör i containern (bash tills en agent installeras där). + ## Bygga & testa ```bash diff --git a/helmd/api.go b/helmd/api.go index a552473..4bd9e71 100644 --- a/helmd/api.go +++ b/helmd/api.go @@ -356,6 +356,8 @@ func serve(cfg *Config) error { return err } api := &API{cfg: cfg, mgr: mgr, shares: shares, ntfy: ntfy} + mux := api.routes() + mountWeb(mux) log.Printf("helmd %s lyssnar på http://%s (token i %s)", version, cfg.Listen, cfg.path) - return http.ListenAndServe(cfg.Listen, api.routes()) + return http.ListenAndServe(cfg.Listen, mux) } diff --git a/helmd/scripts/integration-test.sh b/helmd/scripts/integration-test.sh index 6abd95e..dbebca8 100755 --- a/helmd/scripts/integration-test.sh +++ b/helmd/scripts/integration-test.sh @@ -43,6 +43,13 @@ CODE=$(curl -s -o /dev/null -w '%{http_code}' $API/api/sessions) CODE=$(curl -s -o /dev/null -w '%{http_code}' "$API/api/sessions?token=$TOKEN") [ "$CODE" = 200 ] && ok "query-token => 200" || fail "query-token gav $CODE" +say "webbklient (inbäddad)" +curl -s $API/ > /tmp/ui.html +expect_contains /tmp/ui.html 'agent-helm' "index.html serveras utan auth" +curl -s $API/app.js | grep -q 'ansiToHtml' && ok "app.js serveras" || fail "app.js" +CODE=$(curl -s -o /dev/null -w '%{http_code}' $API/style.css) +[ "$CODE" = 200 ] && ok "style.css => 200" || fail "style.css gav $CODE" + say "stäng av ntfy för testet (PUT /api/config)" curl_api -X PUT -d '{"ntfy":{"server":"https://ntfy.brasse-pc.eu","topic":"agent-helm","allowed_topics":["agent-helm","claude"],"enabled":false,"notify_on_question":false}}' \ $API/api/config > /tmp/cfg.json diff --git a/helmd/web/ansi.js b/helmd/web/ansi.js new file mode 100644 index 0000000..5ba93ce --- /dev/null +++ b/helmd/web/ansi.js @@ -0,0 +1,78 @@ +// Minimal ANSI(SGR) -> HTML. Stödjer 16/256-färg, truecolor, bold/dim/ +// invers/understruket. Allt annat (cursorstyrning m.m.) strippas — +// helmd skickar capture-pane-text, så bara färgkoder förekommer. + +const BASE = [ + "#1c1f26", "#e5605c", "#3fca7c", "#e0a63f", + "#4f9cf9", "#b877d9", "#3fc4c4", "#cfd6e4", + "#5b6472", "#ff7b76", "#5fe09a", "#ffc65c", + "#7bc4ff", "#d49df0", "#6ee0e0", "#ffffff", +]; + +function color256(n) { + n = Number(n); + if (n < 16) return BASE[n]; + if (n < 232) { + const c = n - 16, v = [0, 95, 135, 175, 215, 255]; + return `rgb(${v[(c / 36) | 0]},${v[((c / 6) | 0) % 6]},${v[c % 6]})`; + } + const g = 8 + (n - 232) * 10; + return `rgb(${g},${g},${g})`; +} + +const esc = (s) => s.replace(/&/g, "&").replace(//g, ">"); + +export function ansiToHtml(text) { + let out = "", open = false; + const st = { fg: null, bg: null, bold: false, dim: false, ul: false, inv: false }; + + const flushSpan = () => { if (open) { out += ""; open = false; } }; + const openSpan = () => { + const css = []; + let fg = st.fg, bg = st.bg; + if (st.inv) [fg, bg] = [bg || "#cfd6e4", fg || "#05070b"]; + if (fg) css.push(`color:${fg}`); + if (bg) css.push(`background:${bg}`); + if (st.bold) css.push("font-weight:700"); + if (st.dim) css.push("opacity:.6"); + if (st.ul) css.push("text-decoration:underline"); + if (css.length) { out += ``; open = true; } + }; + + // dela på escape-sekvenser; behåll SGR, släng övriga + const parts = text.split(/(\x1b\[[0-9;:]*[A-Za-z])/); + for (const p of parts) { + if (!p) continue; + if (p.startsWith("\x1b[")) { + if (!p.endsWith("m")) continue; // icke-SGR: ignorera + const codes = p.slice(2, -1).split(/[;:]/).map((x) => (x === "" ? 0 : Number(x))); + for (let i = 0; i < codes.length; i++) { + const c = codes[i]; + if (c === 0) { st.fg = st.bg = null; st.bold = st.dim = st.ul = st.inv = false; } + else if (c === 1) st.bold = true; + else if (c === 2) st.dim = true; + else if (c === 4) st.ul = true; + else if (c === 7) st.inv = true; + else if (c === 22) { st.bold = st.dim = false; } + else if (c === 24) st.ul = false; + else if (c === 27) st.inv = false; + else if (c >= 30 && c <= 37) st.fg = BASE[c - 30]; + else if (c === 38 && codes[i + 1] === 5) { st.fg = color256(codes[i + 2]); i += 2; } + else if (c === 38 && codes[i + 1] === 2) { st.fg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`; i += 4; } + else if (c === 39) st.fg = null; + else if (c >= 40 && c <= 47) st.bg = BASE[c - 40]; + else if (c === 48 && codes[i + 1] === 5) { st.bg = color256(codes[i + 2]); i += 2; } + else if (c === 48 && codes[i + 1] === 2) { st.bg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`; i += 4; } + else if (c === 49) st.bg = null; + else if (c >= 90 && c <= 97) st.fg = BASE[c - 90 + 8]; + else if (c >= 100 && c <= 107) st.bg = BASE[c - 100 + 8]; + } + flushSpan(); + openSpan(); + } else { + out += esc(p.replace(/\x1b./g, "")); + } + } + flushSpan(); + return out; +} diff --git a/helmd/web/app.js b/helmd/web/app.js new file mode 100644 index 0000000..b5ecf01 --- /dev/null +++ b/helmd/web/app.js @@ -0,0 +1,335 @@ +import { ansiToHtml } from "./ansi.js"; + +// ---------- state ---------- +let token = localStorage.getItem("helmd_token") || ""; +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); + +// ---------- 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("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 showLogin(err = "") { + stopLive(); + $("login").classList.remove("hidden"); + $("app").classList.add("hidden"); + $("loginError").textContent = err; +} + +async function tryConnect() { + try { + const h = await api("/api/health"); + $("login").classList.add("hidden"); + $("app").classList.remove("hidden"); + $("verInfo").textContent = "helmd " + h.version; + startLive(); + 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 = () => { + token = $("tokenInput").value.trim(); + localStorage.setItem("helmd_token", token); + tryConnect(); +}; +$("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") $("tokenSave").click(); }); +$("logoutBtn").onclick = () => { localStorage.removeItem("helmd_token"); token = ""; showLogin(); }; + +// ---------- 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 ---------- +function startLive() { + stopLive(); + es = new EventSource("/api/events?token=" + encodeURIComponent(token)); + es.onopen = () => $("connDot").classList.add("ok"); + es.onerror = () => $("connDot").classList.remove("ok"); + es.addEventListener("screen", (e) => { + const ev = JSON.parse(e.data); + if (ev.session === current) refreshScreen(); + refreshSessions(); + }); + es.addEventListener("question", (e) => { + const ev = JSON.parse(e.data); + if (ev.session === current) renderQuestion(ev.data); + }); + es.addEventListener("session", () => refreshSessions()); + es.addEventListener("share", (e) => { + 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() { + sessions = (await api("/api/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) return; + try { + const s = sessions.find((x) => x.name === current); + if (s) currentSeq = s.seq; + const txt = await api(`/api/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(`/api/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(`/api/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(); + try { + await post("/api/sessions", { + name: $("newSessName").value.trim(), + cmd: $("newSessCmd").value.trim(), + cwd: $("newSessCwd").value.trim(), + }); + $("newSessForm").reset(); + $("newSessForm").classList.add("hidden"); + await refreshSessions(); + selectSession($("newSessName").value.trim() || sessions[sessions.length - 1]?.name); + } catch (err) { alert(err.message); } +}; + +$("killBtn").onclick = async () => { + if (!current || !confirm(`Döda sessionen ${current}?`)) return; + await api(`/api/sessions/${current}`, { method: "DELETE" }); + current = null; + refreshSessions(); + $("sessPane").classList.add("hidden"); + $("noSess").classList.remove("hidden"); +}; + +const sendKeys = (keys) => current && post(`/api/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(`/api/sessions/${current}/mode`, { action: "cycle" }).catch(() => {}); + +$("promptForm").onsubmit = async (e) => { + e.preventDefault(); + const t = $("promptText").value; + if (!t.trim() || !current) return; + try { + await post(`/api/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() { + shares = (await api("/api/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(`/api/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 = `/api/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(`/api/shares/${sh.id}/raw`); + v.innerHTML = `
${mdToHtml(txt)}
`; + } else { + const txt = await api(`/api/shares/${sh.id}/raw`); + v.innerHTML = `
${escapeHtml(txt)}
`; + } +} + +// ---------- inställningar ---------- +async function loadConfig() { + const c = await api("/api/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("/api/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; } +}; + +// ---------- 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}
    ` : `
      ${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 ---------- +if (token) tryConnect(); else showLogin(); diff --git a/helmd/web/index.html b/helmd/web/index.html new file mode 100644 index 0000000..b80188d --- /dev/null +++ b/helmd/web/index.html @@ -0,0 +1,110 @@ + + + + + + +agent-helm + + + + + + + + + + + + diff --git a/helmd/web/manifest.json b/helmd/web/manifest.json new file mode 100644 index 0000000..55ce948 --- /dev/null +++ b/helmd/web/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "agent-helm", + "short_name": "helm", + "description": "Fjärrstyr CLI-agenter (agy m.fl.) via helmd", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0e14", + "theme_color": "#0b0e14", + "icons": [ + { + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%230b0e14'/%3E%3Ctext x='50' y='72' font-size='60' text-anchor='middle'%3E⛵%3C/text%3E%3C/svg%3E", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/helmd/web/style.css b/helmd/web/style.css new file mode 100644 index 0000000..2d6553a --- /dev/null +++ b/helmd/web/style.css @@ -0,0 +1,165 @@ +:root { + --bg: #0b0e14; + --bg2: #12161f; + --bg3: #1a2029; + --border: #262d3a; + --fg: #d6dbe5; + --muted: #8a93a5; + --accent: #4f9cf9; + --accent2: #7bc4ff; + --green: #3fca7c; + --red: #e5605c; + --amber: #e0a63f; + --radius: 10px; + font-size: 15px; +} +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + background: var(--bg); color: var(--fg); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} +.hidden { display: none !important; } +button { + background: var(--bg3); color: var(--fg); border: 1px solid var(--border); + border-radius: 7px; padding: 6px 12px; cursor: pointer; font: inherit; +} +button:hover { border-color: var(--accent); } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button.primary:hover { background: var(--accent2); } +button.danger { color: var(--red); } +button.danger:hover { border-color: var(--red); } +input, textarea { + background: var(--bg2); color: var(--fg); border: 1px solid var(--border); + border-radius: 7px; padding: 8px 10px; font: inherit; width: 100%; +} +input:focus, textarea:focus { outline: none; border-color: var(--accent); } +code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; } +.error { color: var(--red); min-height: 1.2em; } +.muted { color: var(--muted); } + +/* ---- login ---- */ +.login { + height: 100%; display: flex; align-items: center; justify-content: center; +} +.login-card { + background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); + padding: 32px; width: min(420px, 92vw); display: flex; flex-direction: column; gap: 12px; +} +.login-card h1 { margin: 0 0 4px; font-size: 1.5rem; } +.login-card p { margin: 0; color: var(--muted); } + +/* ---- app frame ---- */ +.app { height: 100%; display: flex; flex-direction: column; } +header { + display: flex; align-items: center; gap: 16px; + padding: 10px 16px; background: var(--bg2); border-bottom: 1px solid var(--border); +} +.brand { font-weight: 600; } +nav { display: flex; gap: 4px; flex: 1; } +.tab { border: none; background: none; color: var(--muted); padding: 8px 12px; position: relative; } +.tab.active { color: var(--fg); } +.tab.active::after { + content: ""; position: absolute; left: 10px; right: 10px; bottom: -1px; + height: 2px; background: var(--accent); border-radius: 2px; +} +.badge { + background: var(--accent); color: #fff; border-radius: 9px; + font-size: 0.7rem; padding: 1px 6px; margin-left: 6px; +} +.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--red); } +.dot.ok { background: var(--green); } + +.tab-page { flex: 1; min-height: 0; display: flex; } + +/* ---- sessions ---- */ +.sess-list { + width: 240px; border-right: 1px solid var(--border); background: var(--bg2); + display: flex; flex-direction: column; padding: 10px; gap: 8px; overflow-y: auto; +} +.sess-head { display: flex; justify-content: space-between; align-items: center; color: var(--muted); } +.sess-item { + padding: 10px 12px; border-radius: 7px; cursor: pointer; border: 1px solid transparent; + display: flex; align-items: center; gap: 8px; +} +.sess-item:hover { background: var(--bg3); } +.sess-item.active { background: var(--bg3); border-color: var(--border); } +.sess-item .dot { width: 8px; height: 8px; flex: none; } +.sess-item .q { color: var(--amber); margin-left: auto; } +#newSessForm { display: flex; flex-direction: column; gap: 8px; } +.row { display: flex; gap: 8px; align-items: center; } + +.sess-view { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--muted); } +#sessPane { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.sess-toolbar { + display: flex; gap: 6px; align-items: center; padding: 8px 12px; + border-bottom: 1px solid var(--border); flex-wrap: wrap; +} +.sess-toolbar .spacer { flex: 1; } +.sess-toolbar button { padding: 4px 9px; font-size: 0.85rem; } +#sessTitle { font-weight: 600; } + +.screen { + flex: 1; margin: 0; padding: 12px; overflow: auto; background: #05070b; + font: 12.5px/1.35 ui-monospace, "JetBrains Mono", Consolas, monospace; + white-space: pre; color: #cfd6e4; +} +#promptForm { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); } +#promptForm textarea { resize: vertical; } + +/* ---- question card ---- */ +.question { + margin: 10px 12px 0; padding: 14px; border: 1px solid var(--amber); + border-radius: var(--radius); background: color-mix(in srgb, var(--amber) 8%, var(--bg2)); +} +.question h3 { margin: 0 0 10px; font-size: 0.95rem; } +.question .opts { display: flex; flex-direction: column; gap: 6px; } +.question .opts button { + text-align: left; white-space: normal; +} +.question .opts button.selected { border-color: var(--amber); } +.question .opts button:hover { border-color: var(--accent); } + +/* ---- shares ---- */ +.shares-wrap { flex: 1; display: flex; min-height: 0; } +.share-list { + width: 280px; border-right: 1px solid var(--border); background: var(--bg2); + overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 6px; +} +.share-item { + padding: 10px 12px; border-radius: 7px; cursor: pointer; border: 1px solid transparent; +} +.share-item:hover, .share-item.active { background: var(--bg3); border-color: var(--border); } +.share-item .name { font-weight: 600; word-break: break-all; } +.share-item .meta { color: var(--muted); font-size: 0.8rem; } +.share-item .del { float: right; } +.share-view { flex: 1; min-width: 0; overflow: auto; padding: 16px; } +.share-view iframe { + width: 100%; height: 100%; border: 1px solid var(--border); border-radius: 7px; background: #fff; +} +.share-view img { max-width: 100%; border-radius: 7px; } +.share-view pre { + background: var(--bg2); padding: 14px; border-radius: 7px; overflow-x: auto; +} +.md { max-width: 820px; line-height: 1.55; } +.md h1, .md h2, .md h3 { border-bottom: 1px solid var(--border); padding-bottom: 4px; } +.md pre { background: var(--bg2); padding: 12px; border-radius: 7px; overflow-x: auto; } +.md code { background: var(--bg3); } +.md a { color: var(--accent2); } + +/* ---- settings ---- */ +.cfg { max-width: 560px; padding: 24px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; } +.cfg h2 { margin: 12px 0 2px; font-size: 1.05rem; } +.cfg label { display: flex; flex-direction: column; gap: 4px; color: var(--muted); font-size: 0.9rem; } +.cfg label.check { flex-direction: row; align-items: center; gap: 8px; } +.cfg input[type="checkbox"] { width: auto; } + +/* ---- mobil ---- */ +@media (max-width: 720px) { + .tab-page { flex-direction: column; } + .sess-list { width: 100%; max-height: 30vh; border-right: none; border-bottom: 1px solid var(--border); } + .shares-wrap { flex-direction: column; } + .share-list { width: 100%; max-height: 30vh; } + .screen { font-size: 11px; } +} diff --git a/helmd/webfs.go b/helmd/webfs.go new file mode 100644 index 0000000..27044ca --- /dev/null +++ b/helmd/webfs.go @@ -0,0 +1,29 @@ +package main + +import ( + "embed" + "io/fs" + "net/http" +) + +// Webbklienten byggs in i binären — hela v2 är fortfarande en enda fil. +// +//go:embed web +var webFiles embed.FS + +// mountWeb serves the embedded UI on / (the /api/* routes are more +// specific and win in the mux). +func mountWeb(mux *http.ServeMux) { + sub, err := fs.Sub(webFiles, "web") + if err != nil { + panic(err) + } + fileServer := http.FileServerFS(sub) + mux.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // UI:t behöver ingen auth (koden är publik i repot); API:t + // skyddas separat. Strikt CSP så att inget externt kan läcka in. + w.Header().Set("Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'") + fileServer.ServeHTTP(w, r) + })) +}