helmd: inbäddad webbklient (vanilla SPA) + Docker-image för Pi5 (LAN-only)
- 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 <noreply@anthropic.com>
This commit is contained in:
32
.gitea/workflows/helmd-image.yaml
Normal file
32
.gitea/workflows/helmd-image.yaml
Normal file
@@ -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}"
|
||||||
21
doc/plan.md
21
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`).
|
(event-pushar + `POST /api/notify`, topics `agent-helm`/`claude`).
|
||||||
- Isolerat integrationstest i Docker (26 tester), CI-release
|
- Isolerat integrationstest i Docker (26 tester), CI-release
|
||||||
`helmd-latest` (x64 + arm64).
|
`helmd-latest` (x64 + arm64).
|
||||||
2. ⬜ **Webbklient v2** (`packages/web`): porta till helmd-API:t.
|
2. ✅ **Webbklient v2** (`helmd/web/`): **ramverksfri** ES-modul-SPA,
|
||||||
- Sessionslista, skärmvy (poll + SSE), promptfält, frågekort med
|
inbäddad i binären med `go:embed` — hela v2 är fortfarande EN fil,
|
||||||
knappar, läges-/inställningsvy.
|
ingen Node-toolchain i CI. (Beslut: vanilla i stället för Vue-port —
|
||||||
- Delningsflik: html/md renderas, png/jpg visas, md till html.
|
UI-ytan är liten, eslint/byggkedjan försvinner, agenter underhåller
|
||||||
- Uppdaterad look. Serveras statiskt av helmd (embed) eller nginx.
|
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.
|
3. ⬜ **Android-klient**: nytt projekt eller Capacitor-port av PWA:n.
|
||||||
- Egen SSH-tunnel (t.ex. sshj/JSch) → Pi5 → helmd; token +
|
- Egen SSH-tunnel (t.ex. sshj/JSch) → Pi5 → helmd; token +
|
||||||
värdprofil i appen; push via ntfy-appen eller UnifiedPush.
|
värdprofil i appen; push via ntfy-appen eller UnifiedPush.
|
||||||
|
|||||||
4
helmd/.dockerignore
Normal file
4
helmd/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
build/
|
||||||
|
scripts/
|
||||||
|
testdata/
|
||||||
|
README.md
|
||||||
15
helmd/Dockerfile
Normal file
15
helmd/Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -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
|
## Bygga & testa
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -356,6 +356,8 @@ func serve(cfg *Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
api := &API{cfg: cfg, mgr: mgr, shares: shares, ntfy: ntfy}
|
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=$(curl -s -o /dev/null -w '%{http_code}' "$API/api/sessions?token=$TOKEN")
|
||||||
[ "$CODE" = 200 ] && ok "query-token => 200" || fail "query-token gav $CODE"
|
[ "$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 '<title>agent-helm</title>' "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)"
|
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}}' \
|
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
|
$API/api/config > /tmp/cfg.json
|
||||||
|
|||||||
78
helmd/web/ansi.js
Normal file
78
helmd/web/ansi.js
Normal file
@@ -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, "<").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 += "</span>"; 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 += `<span style="${css.join(";")}">`; 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;
|
||||||
|
}
|
||||||
335
helmd/web/app.js
Normal file
335
helmd/web/app.js
Normal file
@@ -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 = `<span class="dot ${s.alive ? "ok" : ""}"></span>
|
||||||
|
<span>${s.name}</span>${s.question ? '<span class="q" title="väntar på svar">❓</span>' : ""}`;
|
||||||
|
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 = `<h3>❓ ${escapeHtml(q.text)}</h3><div class="opts"></div>`;
|
||||||
|
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 = `<button class="del danger" title="ta bort">✕</button>
|
||||||
|
<div class="name">${escapeHtml(sh.name)}</div>
|
||||||
|
<div class="meta">${when}${sh.session ? " · " + escapeHtml(sh.session) : ""}${sh.note ? "<br>" + escapeHtml(sh.note) : ""}</div>`;
|
||||||
|
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 = `<img src="${raw}" alt="${escapeHtml(sh.name)}">`;
|
||||||
|
} else if (["html", "htm", "pdf"].includes(ext)) {
|
||||||
|
v.innerHTML = `<iframe src="${raw}" sandbox="allow-scripts"></iframe>`;
|
||||||
|
} else if (ext === "md") {
|
||||||
|
const txt = await api(`/api/shares/${sh.id}/raw`);
|
||||||
|
v.innerHTML = `<div class="md">${mdToHtml(txt)}</div>`;
|
||||||
|
} else {
|
||||||
|
const txt = await api(`/api/shares/${sh.id}/raw`);
|
||||||
|
v.innerHTML = `<pre>${escapeHtml(txt)}</pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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, ">").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 `<pre>${escapeHtml(b.replace(/^```\w*\n?|```$/g, ""))}</pre>`;
|
||||||
|
if (/^#{1,6} /.test(b)) {
|
||||||
|
const n = b.match(/^#+/)[0].length;
|
||||||
|
return `<h${n}>${inline(b.replace(/^#+ /, ""))}</h${n}>`;
|
||||||
|
}
|
||||||
|
if (/^([-*] |\d+\. )/m.test(b)) {
|
||||||
|
const items = b.split("\n").filter(Boolean).map((l) => `<li>${inline(l.replace(/^([-*]|\d+\.) /, ""))}</li>`).join("");
|
||||||
|
return /^\d/.test(b) ? `<ol>${items}</ol>` : `<ul>${items}</ul>`;
|
||||||
|
}
|
||||||
|
if (/^(-{3,}|_{3,}|\*{3,})$/.test(b.trim())) return "<hr>";
|
||||||
|
return `<p>${inline(b)}</p>`;
|
||||||
|
}).join("\n");
|
||||||
|
function inline(s) {
|
||||||
|
return escapeHtml(s)
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- start ----------
|
||||||
|
if (token) tryConnect(); else showLogin();
|
||||||
110
helmd/web/index.html
Normal file
110
helmd/web/index.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="sv">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0b0e14">
|
||||||
|
<title>agent-helm</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='80' font-size='80'%3E⛵%3C/text%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="login" class="login hidden">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>⛵ agent-helm</h1>
|
||||||
|
<p>Klistra in API-token från servern (<code>helmd token</code>).</p>
|
||||||
|
<input id="tokenInput" type="password" placeholder="token" autocomplete="off">
|
||||||
|
<button id="tokenSave" class="primary">Anslut</button>
|
||||||
|
<p id="loginError" class="error"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="app" class="app hidden">
|
||||||
|
<header>
|
||||||
|
<span class="brand">⛵ agent-helm</span>
|
||||||
|
<nav>
|
||||||
|
<button data-tab="sessions" class="tab active">Sessioner</button>
|
||||||
|
<button data-tab="shares" class="tab">Delningar<span id="shareBadge" class="badge hidden"></span></button>
|
||||||
|
<button data-tab="settings" class="tab">Inställningar</button>
|
||||||
|
</nav>
|
||||||
|
<span id="connDot" class="dot" title="SSE-anslutning"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="tab-sessions" class="tab-page">
|
||||||
|
<aside class="sess-list">
|
||||||
|
<div class="sess-head">
|
||||||
|
<span>Sessioner</span>
|
||||||
|
<button id="newSessBtn" title="Ny session">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="sessItems"></div>
|
||||||
|
<form id="newSessForm" class="hidden">
|
||||||
|
<input id="newSessName" placeholder="namn (t.ex. agy)" required>
|
||||||
|
<input id="newSessCmd" placeholder="kommando (tomt = agent från config)">
|
||||||
|
<input id="newSessCwd" placeholder="arbetskatalog (tomt = default)">
|
||||||
|
<div class="row">
|
||||||
|
<button type="submit" class="primary">Starta</button>
|
||||||
|
<button type="button" id="newSessCancel">Avbryt</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="sess-view">
|
||||||
|
<div id="noSess" class="empty">Ingen session vald — skapa en med +</div>
|
||||||
|
<div id="sessPane" class="hidden">
|
||||||
|
<div class="sess-toolbar">
|
||||||
|
<span id="sessTitle"></span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="keyEsc" title="Escape">Esc</button>
|
||||||
|
<button id="keyUp" title="Pil upp">↑</button>
|
||||||
|
<button id="keyDown" title="Pil ned">↓</button>
|
||||||
|
<button id="keyEnter" title="Enter">⏎</button>
|
||||||
|
<button id="keyCtrlC" title="Ctrl+C">^C</button>
|
||||||
|
<button id="modeBtn" title="Byt läge (Shift+Tab)">Läge</button>
|
||||||
|
<button id="killBtn" class="danger" title="Döda sessionen">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="questionCard" class="question hidden"></div>
|
||||||
|
<pre id="screen" class="screen"></pre>
|
||||||
|
<form id="promptForm">
|
||||||
|
<textarea id="promptText" rows="2" placeholder="Skriv prompt… (Enter skickar, Shift+Enter = ny rad)"></textarea>
|
||||||
|
<button type="submit" class="primary">Skicka</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="tab-shares" class="tab-page hidden">
|
||||||
|
<div class="shares-wrap">
|
||||||
|
<div id="shareItems" class="share-list"></div>
|
||||||
|
<div id="shareView" class="share-view empty">Välj en delning i listan</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="tab-settings" class="tab-page hidden">
|
||||||
|
<form id="cfgForm" class="cfg">
|
||||||
|
<h2>Agent</h2>
|
||||||
|
<label>Kommando <input id="cfgCmd"></label>
|
||||||
|
<label>Argument (mellanslagsseparerade) <input id="cfgArgs"></label>
|
||||||
|
<label>Arbetskatalog <input id="cfgWorkdir"></label>
|
||||||
|
<label>Poll-intervall (ms) <input id="cfgPoll" type="number" min="100" step="100"></label>
|
||||||
|
<h2>ntfy</h2>
|
||||||
|
<label class="check"><input id="cfgNtfyOn" type="checkbox"> ntfy aktiverat</label>
|
||||||
|
<label class="check"><input id="cfgNtfyQ" type="checkbox"> pusha vid ny fråga</label>
|
||||||
|
<label>Server <input id="cfgNtfyServer"></label>
|
||||||
|
<label>Topic <input id="cfgNtfyTopic"></label>
|
||||||
|
<div class="row">
|
||||||
|
<button type="submit" class="primary">Spara</button>
|
||||||
|
<span id="cfgStatus"></span>
|
||||||
|
</div>
|
||||||
|
<h2>Klient</h2>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" id="logoutBtn" class="danger">Glöm token (logga ut)</button>
|
||||||
|
<span id="verInfo" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
helmd/web/manifest.json
Normal file
17
helmd/web/manifest.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
165
helmd/web/style.css
Normal file
165
helmd/web/style.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
29
helmd/webfs.go
Normal file
29
helmd/webfs.go
Normal file
@@ -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)
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user