fixed docker build for webui

added themes and token handling
This commit is contained in:
Stefan Rinke 2026-02-15 12:02:36 +01:00
parent 06ca0f422c
commit 5da6a3bba1
4 changed files with 169 additions and 20 deletions

View file

@ -3,7 +3,7 @@
# ============================================================ # ============================================================
FROM golang:1.26.0-alpine AS builder 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 WORKDIR /src
@ -13,6 +13,7 @@ RUN go mod download
# Copy source and build # Copy source and build
COPY . . COPY . .
RUN make ui-build
RUN make build RUN make build
# ============================================================ # ============================================================
@ -25,6 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl
# Copy binary # Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw 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 # Create picoclaw home directory
RUN /usr/local/bin/picoclaw onboard RUN /usr/local/bin/picoclaw onboard

View file

@ -267,6 +267,8 @@ func (c *WebUIChannel) staticHandler() http.Handler {
func (c *WebUIChannel) findUIRoot() string { func (c *WebUIChannel) findUIRoot() string {
candidates := []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"), filepath.Join("ui", "dist"),
"ui", "ui",
} }

View file

@ -2,11 +2,41 @@ import './style.css'
const app = document.querySelector('#app') 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 = ` app.innerHTML = `
<div class="wrap"> <div class="wrap">
<header class="header"> <header class="header">
<div class="title">PicoClaw</div> <div class="title">PicoClaw</div>
<div class="status" id="status">disconnected</div> <div class="header-right">
<label class="theme" for="theme">
<span class="theme-label">Theme</span>
<select class="theme-select" id="theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<div class="status" id="status">disconnected</div>
</div>
</header> </header>
<main class="chat" id="chat"></main> <main class="chat" id="chat"></main>
@ -20,11 +50,38 @@ app.innerHTML = `
const chat = document.querySelector('#chat') const chat = document.querySelector('#chat')
const statusEl = document.querySelector('#status') const statusEl = document.querySelector('#status')
const themeSelect = document.querySelector('#theme')
const form = document.querySelector('#form') const form = document.querySelector('#form')
const input = document.querySelector('#input') const input = document.querySelector('#input')
const chatId = 'browser' 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) { function addMessage(role, text) {
const item = document.createElement('div') const item = document.createElement('div')
@ -41,7 +98,7 @@ function addMessage(role, text) {
function wsUrl() { function wsUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' 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}` return `${proto}//${location.host}/ws?chat_id=${encodeURIComponent(chatId)}${tokenPart}`
} }
@ -61,6 +118,12 @@ function connect() {
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
setStatus('connected') setStatus('connected')
if (tokenCameFromUrl && gatewayToken) {
localStorage.setItem(TOKEN_STORAGE_KEY, gatewayToken)
removeTokenFromUrl()
tokenCameFromUrl = false
}
}) })
ws.addEventListener('close', () => { ws.addEventListener('close', () => {

View file

@ -1,11 +1,59 @@
:root { :root {
color-scheme: light dark; 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; --bg: #0b1020;
--panel: rgba(255, 255, 255, 0.06); --panel: rgba(17, 24, 39, 0.72);
--border: rgba(255, 255, 255, 0.10); --panel-solid: #111827;
--border: rgba(255, 255, 255, 0.12);
--text: rgba(255, 255, 255, 0.92); --text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.60); --muted: rgba(255, 255, 255, 0.62);
--accent: #7c5cff; --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; } * { box-sizing: border-box; }
@ -14,9 +62,10 @@ html, body {
height: 100%; height: 100%;
margin: 0; margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; 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%), background:
radial-gradient(1000px 600px at 80% 30%, rgba(0, 209, 255, 0.14), transparent 60%), radial-gradient(1000px 600px at 15% 10%, rgba(37, 99, 235, 0.12), transparent 60%),
var(--bg); radial-gradient(900px 500px at 85% 25%, rgba(99, 102, 241, 0.10), transparent 55%),
var(--bg);
color: var(--text); color: var(--text);
} }
@ -39,6 +88,7 @@ html, body {
border-radius: 14px; border-radius: 14px;
background: var(--panel); background: var(--panel);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
box-shadow: var(--shadow);
} }
.title { .title {
@ -55,6 +105,34 @@ html, body {
.status[data-state="connecting"] { color: #faa61a; } .status[data-state="connecting"] { color: #faa61a; }
.status[data-state="disconnected"] { color: #ed4245; } .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 { .chat {
overflow: auto; overflow: auto;
padding: 16px; padding: 16px;
@ -62,6 +140,7 @@ html, body {
border-radius: 14px; border-radius: 14px;
background: var(--panel); background: var(--panel);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
box-shadow: var(--shadow);
} }
.msg { .msg {
@ -78,13 +157,13 @@ html, body {
border-radius: 12px; border-radius: 12px;
line-height: 1.4; line-height: 1.4;
border: 1px solid var(--border); border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05); background: var(--panel-solid);
white-space: pre-wrap; white-space: pre-wrap;
} }
.msg.user .bubble { .msg.user .bubble {
background: rgba(124, 92, 255, 0.22); background: rgba(37, 99, 235, 0.12);
border-color: rgba(124, 92, 255, 0.42); border-color: rgba(37, 99, 235, 0.28);
} }
.composer { .composer {
@ -96,6 +175,7 @@ html, body {
border-radius: 14px; border-radius: 14px;
background: var(--panel); background: var(--panel);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
box-shadow: var(--shadow);
} }
.input { .input {
@ -103,25 +183,25 @@ html, body {
padding: 12px 12px; padding: 12px 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.25); background: var(--input-bg);
color: var(--text); color: var(--text);
outline: none; outline: none;
} }
.input:focus { .input:focus {
border-color: rgba(124, 92, 255, 0.8); border-color: rgba(37, 99, 235, 0.65);
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.25); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
} }
.btn { .btn {
padding: 12px 14px; padding: 12px 14px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(124, 92, 255, 0.6); border: 1px solid rgba(37, 99, 235, 0.45);
background: rgba(124, 92, 255, 0.30); background: rgba(37, 99, 235, 0.14);
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
} }
.btn:hover { .btn:hover {
background: rgba(124, 92, 255, 0.42); background: rgba(37, 99, 235, 0.22);
} }