From 5da6a3bba1c463d23996c8db697b0127b0fa6a8b Mon Sep 17 00:00:00 2001 From: Stefan Rinke Date: Sun, 15 Feb 2026 12:02:36 +0100 Subject: [PATCH] fixed docker build for webui added themes and token handling --- Dockerfile | 6 ++- pkg/channels/webui.go | 2 + ui/src/main.js | 69 ++++++++++++++++++++++++-- ui/src/style.css | 112 ++++++++++++++++++++++++++++++++++++------ 4 files changed, 169 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index 433d962f2..2c693cf4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # ============================================================ FROM golang:1.26.0-alpine AS builder -RUN apk add --no-cache git make +RUN apk add --no-cache git make nodejs npm WORKDIR /src @@ -13,6 +13,7 @@ RUN go mod download # Copy source and build COPY . . +RUN make ui-build RUN make build # ============================================================ @@ -25,6 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw +# Copy built Web UI assets +COPY --from=builder /src/ui/dist /usr/local/share/picoclaw/ui/dist + # Create picoclaw home directory RUN /usr/local/bin/picoclaw onboard diff --git a/pkg/channels/webui.go b/pkg/channels/webui.go index 229799de0..1251ec8c5 100644 --- a/pkg/channels/webui.go +++ b/pkg/channels/webui.go @@ -267,6 +267,8 @@ func (c *WebUIChannel) staticHandler() http.Handler { func (c *WebUIChannel) findUIRoot() string { candidates := []string{ + filepath.Join(string(os.PathSeparator), "usr", "local", "share", "picoclaw", "ui", "dist"), + filepath.Join(string(os.PathSeparator), "usr", "share", "picoclaw", "ui", "dist"), filepath.Join("ui", "dist"), "ui", } diff --git a/ui/src/main.js b/ui/src/main.js index e037981a8..562684137 100644 --- a/ui/src/main.js +++ b/ui/src/main.js @@ -2,11 +2,41 @@ import './style.css' const app = document.querySelector('#app') +const THEME_STORAGE_KEY = 'picoclaw.theme' + +function getStoredTheme() { + const v = localStorage.getItem(THEME_STORAGE_KEY) + if (v === 'light' || v === 'dark' || v === 'system') return v + return 'system' +} + +function applyTheme(mode) { + const root = document.documentElement + if (mode === 'system') { + root.removeAttribute('data-theme') + } else { + root.setAttribute('data-theme', mode) + } +} + +let themeMode = getStoredTheme() +applyTheme(themeMode) + app.innerHTML = `
PicoClaw
-
disconnected
+
+ +
disconnected
+
@@ -20,11 +50,38 @@ app.innerHTML = ` const chat = document.querySelector('#chat') const statusEl = document.querySelector('#status') +const themeSelect = document.querySelector('#theme') const form = document.querySelector('#form') const input = document.querySelector('#input') const chatId = 'browser' -const pageToken = new URLSearchParams(location.search).get('token') || '' +const TOKEN_STORAGE_KEY = 'picoclaw.gateway_token' +const urlParams = new URLSearchParams(location.search) +const tokenFromUrl = urlParams.get('token') || '' +const tokenFromStorage = localStorage.getItem(TOKEN_STORAGE_KEY) || '' +let gatewayToken = tokenFromUrl || tokenFromStorage +let tokenCameFromUrl = !!tokenFromUrl + +function removeTokenFromUrl() { + const p = new URLSearchParams(location.search) + if (!p.has('token')) return + p.delete('token') + const qs = p.toString() + const newUrl = `${location.pathname}${qs ? `?${qs}` : ''}${location.hash || ''}` + history.replaceState(null, '', newUrl) +} + +themeSelect.value = themeMode +themeSelect.addEventListener('change', () => { + themeMode = themeSelect.value + localStorage.setItem(THEME_STORAGE_KEY, themeMode) + applyTheme(themeMode) +}) + +const media = window.matchMedia('(prefers-color-scheme: dark)') +media.addEventListener('change', () => { + if (themeMode === 'system') applyTheme('system') +}) function addMessage(role, text) { const item = document.createElement('div') @@ -41,7 +98,7 @@ function addMessage(role, text) { function wsUrl() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' - const tokenPart = pageToken ? `&token=${encodeURIComponent(pageToken)}` : '' + const tokenPart = gatewayToken ? `&token=${encodeURIComponent(gatewayToken)}` : '' return `${proto}//${location.host}/ws?chat_id=${encodeURIComponent(chatId)}${tokenPart}` } @@ -61,6 +118,12 @@ function connect() { ws.addEventListener('open', () => { setStatus('connected') + + if (tokenCameFromUrl && gatewayToken) { + localStorage.setItem(TOKEN_STORAGE_KEY, gatewayToken) + removeTokenFromUrl() + tokenCameFromUrl = false + } }) ws.addEventListener('close', () => { diff --git a/ui/src/style.css b/ui/src/style.css index fbc439e17..24845387c 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -1,11 +1,59 @@ :root { color-scheme: light dark; + + --bg: #f6f7fb; + --panel: rgba(255, 255, 255, 0.8); + --panel-solid: #ffffff; + --border: rgba(17, 24, 39, 0.12); + --text: #111827; + --muted: #6b7280; + --accent: #2563eb; + --accent-contrast: #ffffff; + --input-bg: rgba(255, 255, 255, 0.9); + --shadow: 0 1px 2px rgba(17, 24, 39, 0.06), 0 8px 24px rgba(17, 24, 39, 0.08); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b1020; + --panel: rgba(17, 24, 39, 0.72); + --panel-solid: #111827; + --border: rgba(255, 255, 255, 0.12); + --text: rgba(255, 255, 255, 0.92); + --muted: rgba(255, 255, 255, 0.62); + --accent: #3b82f6; + --accent-contrast: #0b1020; + --input-bg: rgba(0, 0, 0, 0.25); + --shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 10px 30px rgba(0, 0, 0, 0.45); + } +} + +html[data-theme="light"] { + color-scheme: light; + --bg: #f6f7fb; + --panel: rgba(255, 255, 255, 0.85); + --panel-solid: #ffffff; + --border: rgba(17, 24, 39, 0.12); + --text: #111827; + --muted: #6b7280; + --accent: #2563eb; + --accent-contrast: #ffffff; + --input-bg: rgba(255, 255, 255, 0.92); + --shadow: 0 1px 2px rgba(17, 24, 39, 0.06), 0 8px 24px rgba(17, 24, 39, 0.08); +} + +html[data-theme="dark"] { + color-scheme: dark; --bg: #0b1020; - --panel: rgba(255, 255, 255, 0.06); - --border: rgba(255, 255, 255, 0.10); + --panel: rgba(17, 24, 39, 0.72); + --panel-solid: #111827; + --border: rgba(255, 255, 255, 0.12); --text: rgba(255, 255, 255, 0.92); - --muted: rgba(255, 255, 255, 0.60); - --accent: #7c5cff; + --muted: rgba(255, 255, 255, 0.62); + --accent: #3b82f6; + --accent-contrast: #0b1020; + --input-bg: rgba(0, 0, 0, 0.25); + --shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 10px 30px rgba(0, 0, 0, 0.45); } * { box-sizing: border-box; } @@ -14,9 +62,10 @@ html, body { height: 100%; margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; - background: radial-gradient(1200px 800px at 10% 10%, rgba(124, 92, 255, 0.25), transparent 60%), - radial-gradient(1000px 600px at 80% 30%, rgba(0, 209, 255, 0.14), transparent 60%), - var(--bg); + background: + radial-gradient(1000px 600px at 15% 10%, rgba(37, 99, 235, 0.12), transparent 60%), + radial-gradient(900px 500px at 85% 25%, rgba(99, 102, 241, 0.10), transparent 55%), + var(--bg); color: var(--text); } @@ -39,6 +88,7 @@ html, body { border-radius: 14px; background: var(--panel); backdrop-filter: blur(10px); + box-shadow: var(--shadow); } .title { @@ -55,6 +105,34 @@ html, body { .status[data-state="connecting"] { color: #faa61a; } .status[data-state="disconnected"] { color: #ed4245; } +.header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.theme { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; +} + +.theme-select { + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); + outline: none; +} + +.theme-select:focus { + border-color: rgba(37, 99, 235, 0.65); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); +} + .chat { overflow: auto; padding: 16px; @@ -62,6 +140,7 @@ html, body { border-radius: 14px; background: var(--panel); backdrop-filter: blur(10px); + box-shadow: var(--shadow); } .msg { @@ -78,13 +157,13 @@ html, body { border-radius: 12px; line-height: 1.4; border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.05); + background: var(--panel-solid); white-space: pre-wrap; } .msg.user .bubble { - background: rgba(124, 92, 255, 0.22); - border-color: rgba(124, 92, 255, 0.42); + background: rgba(37, 99, 235, 0.12); + border-color: rgba(37, 99, 235, 0.28); } .composer { @@ -96,6 +175,7 @@ html, body { border-radius: 14px; background: var(--panel); backdrop-filter: blur(10px); + box-shadow: var(--shadow); } .input { @@ -103,25 +183,25 @@ html, body { padding: 12px 12px; border-radius: 12px; border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.25); + background: var(--input-bg); color: var(--text); outline: none; } .input:focus { - border-color: rgba(124, 92, 255, 0.8); - box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.25); + border-color: rgba(37, 99, 235, 0.65); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); } .btn { padding: 12px 14px; border-radius: 12px; - border: 1px solid rgba(124, 92, 255, 0.6); - background: rgba(124, 92, 255, 0.30); + border: 1px solid rgba(37, 99, 235, 0.45); + background: rgba(37, 99, 235, 0.14); color: var(--text); cursor: pointer; } .btn:hover { - background: rgba(124, 92, 255, 0.42); + background: rgba(37, 99, 235, 0.22); }