From 604c68cfa7536ee54d3b45cb408505e9dee6fcdc Mon Sep 17 00:00:00 2001 From: Arpit Thukral <20359587+arpit0515@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:55:59 +0000 Subject: [PATCH] minor bug fixes and changes --- handlers.go | 149 +++++------ install.sh | 8 +- main.go | 4 +- system.go | 161 +++++++++--- templates/index.html | 609 +++++++++++++++++++++++++++++++++++-------- 5 files changed, 701 insertions(+), 230 deletions(-) diff --git a/handlers.go b/handlers.go index 47fedc6..fa7b2ba 100644 --- a/handlers.go +++ b/handlers.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" ) @@ -24,8 +25,20 @@ func handleValidateLLM(w http.ResponseWriter, r *http.Request) { apiKey := strings.TrimSpace(r.FormValue("api_key")) model := strings.TrimSpace(r.FormValue("model")) - if provider == "" || apiKey == "" || model == "" { - errorResponse(w, "provider, api_key and model are required") + if provider == "" || model == "" { + errorResponse(w, "provider and model are required") + return + } + + // No key supplied — fall back to whatever is already saved in config + if apiKey == "" { + cfg := readConfig() + if p, ok := cfg.Providers[provider]; ok { + apiKey, _ = p["api_key"].(string) + } + } + if apiKey == "" { + errorResponse(w, "No API key provided and none saved for this provider") return } @@ -200,84 +213,19 @@ func handleInstallService(w http.ResponseWriter, r *http.Request) { return } - ok, msg := installService() + ok, msg := installSystemdService() jsonResponse(w, map[string]interface{}{ "ok": ok, "message": msg, }) } -func installService() (bool, string) { +func installSystemdService() (bool, string) { picocławPath, err := exec.LookPath("picoclaw") if err != nil { - return false, "picoclaw not found in PATH — install PicoClaw first" + return false, "picoclaw not found in PATH" } - osName, _ := runCommand("uname", "-s") - osName = strings.TrimSpace(osName) - - if osName == "Darwin" { - return installLaunchdService(picocławPath) - } - return installSystemdService(picocławPath) -} - -// macOS: launchd plist in ~/Library/LaunchAgents -func installLaunchdService(picocławPath string) (bool, string) { - home, _ := os.UserHomeDir() - launchDir := filepath.Join(home, "Library", "LaunchAgents") - os.MkdirAll(launchDir, 0755) - - logDir := filepath.Join(home, ".picoclaw", "logs") - os.MkdirAll(logDir, 0755) - - plistContent := fmt.Sprintf(` - - - - Label - com.picoclaw.agent - ProgramArguments - - %s - gateway - - RunAtLoad - - KeepAlive - - WorkingDirectory - %s - EnvironmentVariables - - HOME - %s - - StandardOutPath - %s/picoclaw.log - StandardErrorPath - %s/picoclaw.err - - -`, picocławPath, home, home, logDir, logDir) - - plistPath := filepath.Join(launchDir, "com.picoclaw.agent.plist") - if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil { - return false, "Failed to write plist: " + err.Error() - } - - // Unload first in case it was already loaded, ignore error - exec.Command("launchctl", "unload", plistPath).Run() - - out, err := exec.Command("launchctl", "load", plistPath).CombinedOutput() - if err != nil { - return false, "launchctl load failed: " + strings.TrimSpace(string(out)) - } - return true, "Service installed — PicoClaw will start automatically on login" -} - -// Linux: systemd user service -func installSystemdService(picocławPath string) (bool, string) { home, _ := os.UserHomeDir() serviceDir := filepath.Join(home, ".config", "systemd", "user") os.MkdirAll(serviceDir, 0755) @@ -310,16 +258,49 @@ WantedBy=default.target } for _, cmd := range commands { if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil { - msg := strings.TrimSpace(string(out)) - if msg == "" { - msg = "command failed: " + strings.Join(cmd, " ") - } - return false, msg + return false, strings.TrimSpace(string(out)) } } return true, "Service installed and started" } +// ── Restart ────────────────────────────────────────────────────────────────── + +func handleRestartService(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var out string + var err error + if runtime.GOOS == "darwin" { + plistPath := os.ExpandEnv("$HOME/Library/LaunchAgents/com.picoclaw.agent.plist") + exec.Command("launchctl", "unload", plistPath).Run() + out, err = runCommand("launchctl", "load", plistPath) + } else { + out, err = runCommand("systemctl", "--user", "restart", "picoclaw") + } + + if err != nil { + msg := strings.TrimSpace(out) + if msg == "" { + msg = "Restart failed: " + err.Error() + } + errorResponse(w, msg) + return + } + okResponse(w, "Agent restarted", nil) +} + +// ── Local IP ───────────────────────────────────────────────────────────────── + +func handleLocalIP(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "ip": getLocalIP(), + }) +} + // ── Health ──────────────────────────────────────────────────────────────────── func handleHealth(w http.ResponseWriter, r *http.Request) { @@ -434,8 +415,20 @@ func handleGetModels(w http.ResponseWriter, r *http.Request) { provider := strings.TrimSpace(r.FormValue("provider")) apiKey := strings.TrimSpace(r.FormValue("api_key")) - if provider != "openrouter" || apiKey == "" { - errorResponse(w, "provider and api_key required") + if provider != "openrouter" { + errorResponse(w, "provider required") + return + } + + // No key supplied — fall back to whatever is already saved in config + if apiKey == "" { + cfg := readConfig() + if p, ok := cfg.Providers[provider]; ok { + apiKey, _ = p["api_key"].(string) + } + } + if apiKey == "" { + errorResponse(w, "No API key provided and none saved for this provider") return } @@ -449,4 +442,4 @@ func handleGetModels(w http.ResponseWriter, r *http.Request) { "ok": true, "models": models, }) -} +} \ No newline at end of file diff --git a/install.sh b/install.sh index ed3f6b1..bd68f15 100644 --- a/install.sh +++ b/install.sh @@ -30,7 +30,7 @@ if git -C "$REPO_DIR" rev-parse --is-inside-work-tree &>/dev/null; then log "✓ Already up to date (${AFTER:0:7})" fi else - log "⚠ Not a git repo — cloning fresh copy from $GITHUB_REPO..." + log "⚠ Not a git repo - cloning fresh copy from $GITHUB_REPO..." TMP_CLONE=$(mktemp -d) git clone "$GITHUB_REPO" "$TMP_CLONE" >> "$LOG_FILE" 2>&1 cp -r "$TMP_CLONE/." "$REPO_DIR/" @@ -82,7 +82,7 @@ EOF log "✓ Will launch automatically on next boot" ;; *) - log "⏭ Skipping startup autorun — run install.sh again anytime to set it up" + log "⏭ Skipping startup autorun - run install.sh again anytime to set it up" ;; esac fi @@ -112,7 +112,7 @@ if command -v go &>/dev/null; then log "✓ Go already installed: $GO_INSTALLED" else log "" - log "⬇ Go not found — installing Go $GO_VERSION..." + log "⬇ Go not found - installing Go $GO_VERSION..." OS=$(uname -s) if [[ "$OS" == "Darwin" ]]; then @@ -161,7 +161,7 @@ fi [ -z "$LOCAL_IP" ] && LOCAL_IP="localhost" log "" log "================================" -log "✅ Ready — open in your browser:" +log "✅ Ready - open in your browser:" log " 👉 http://$LOCAL_IP:3000" log "================================" log "" diff --git a/main.go b/main.go index 8b453c0..88a433f 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,8 @@ func main() { mux.HandleFunc("/api/health", handleHealth) mux.HandleFunc("/api/install-picoclaw", handleInstallPicoclaw) mux.HandleFunc("/api/models", handleGetModels) + mux.HandleFunc("/api/restart-service", handleRestartService) + mux.HandleFunc("/api/local-ip", handleLocalIP) ip := getLocalIP() fmt.Println(" *** claw-setup is running **** ") @@ -69,4 +71,4 @@ func getLocalIP() string { func runCommand(name string, args ...string) (string, error) { out, err := exec.Command(name, args...).CombinedOutput() return strings.TrimSpace(string(out)), err -} +} \ No newline at end of file diff --git a/system.go b/system.go index 4d9dc86..1102d25 100644 --- a/system.go +++ b/system.go @@ -2,16 +2,18 @@ package main import ( "encoding/json" + "fmt" "net/http" "os" "os/exec" "path/filepath" + "runtime" + "strconv" "strings" ) // SystemStatus that holds everything we check on the Pi type SystemStatus struct { - OS string `json:"os"` PicoclawInstalled bool `json:"picoclaw_installed"` PicoclawVersion string `json:"picoclaw_version"` DiskSpace string `json:"disk_space"` @@ -25,6 +27,7 @@ type SystemStatus struct { TelegramToken string `json:"telegram_token"` TelegramUser string `json:"telegram_user"` ServiceStatus string `json:"service_status"` + OS string `json:"os"` Checklist struct { System bool `json:"system"` Provider bool `json:"provider"` @@ -42,16 +45,6 @@ func handleSystemCheck(w http.ResponseWriter, r *http.Request) { func buildSystemStatus() SystemStatus { var s SystemStatus - - // Detect OS - osName, _ := runCommand("uname", "-s") - osName = strings.TrimSpace(osName) - if osName == "Darwin" { - s.OS = "mac" - } else { - s.OS = "linux" - } - // Check PicoClaw path, err := exec.LookPath("picoclaw") if err == nil && path != "" { @@ -75,17 +68,8 @@ func buildSystemStatus() SystemStatus { } } - // RAM - out, err = runCommand("free", "-h") - if err == nil { - lines := strings.Split(out, "\n") - if len(lines) >= 2{ - fields := strings.Fields(lines[1]) - if len(fields) >=7 { - s.RAM = fields[6] + " free of " + fields[1] - } - } - } + // RAM — OS-aware + s.RAM = getRAM() // Config configPath := getConfigPath() @@ -135,21 +119,14 @@ func buildSystemStatus() SystemStatus { s.HasSoul = true } - // Service status — launchctl on macOS, systemctl on Linux - if s.OS == "mac" { - out, err = runCommand("launchctl", "list", "com.picoclaw.agent") - if err == nil && !strings.Contains(out, "Could not find service") { - s.ServiceStatus = "active" - } else { - s.ServiceStatus = "inactive" - } + // Service status — OS-aware + s.ServiceStatus = getServiceStatus() + + // OS + if runtime.GOOS == "darwin" { + s.OS = "mac" } else { - out, err = runCommand("systemctl", "--user", "is-active", "picoclaw") - if err == nil && strings.TrimSpace(out) == "active" { - s.ServiceStatus = "active" - } else { - s.ServiceStatus = "inactive" - } + s.OS = "linux" } // Checklist @@ -162,6 +139,118 @@ func buildSystemStatus() SystemStatus { return s } +// ------- Service Helpers ------- + +func getServiceStatus() string { + if runtime.GOOS == "darwin" { + // launchctl list returns a line with the label if loaded + out, err := runCommand("launchctl", "list", "com.picoclaw.agent") + if err == nil && !strings.Contains(out, "Could not find") && out != "" { + return "active" + } + return "inactive" + } + // Linux systemd + out, err := runCommand("systemctl", "--user", "is-active", "picoclaw") + if err == nil && strings.TrimSpace(out) == "active" { + return "active" + } + return "inactive" +} + +// ------- RAM Helpers ------- + +func getRAM() string { + if runtime.GOOS == "darwin" { + return getMacRAM() + } + return getLinuxRAM() +} + +// macOS: vm_stat for free pages + sysctl for total +func getMacRAM() string { + // Total RAM via sysctl + totalOut, err := runCommand("sysctl", "-n", "hw.memsize") + if err != nil { + return "unavailable" + } + totalBytes, err := strconv.ParseInt(strings.TrimSpace(totalOut), 10, 64) + if err != nil { + return "unavailable" + } + + // Free pages via vm_stat + vmOut, err := runCommand("vm_stat") + if err != nil { + return "unavailable" + } + + var pageSize int64 = 4096 + var freePages, inactivePages int64 + for _, line := range strings.Split(vmOut, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Mach Virtual Memory Statistics") { + // Extract page size: "page size of 4096 bytes" + var ps int64 + if _, err := fmt.Sscanf(line, "Mach Virtual Memory Statistics: (page size of %d bytes)", &ps); err == nil { + pageSize = ps + } + } + var val int64 + if strings.HasPrefix(line, "Pages free:") { + fmt.Sscanf(strings.TrimRight(strings.Split(line, ":")[1], "."), "%d", &val) + freePages = val + } + if strings.HasPrefix(line, "Pages inactive:") { + fmt.Sscanf(strings.TrimRight(strings.Split(line, ":")[1], "."), "%d", &val) + inactivePages = val + } + } + + freeBytes := (freePages + inactivePages) * pageSize + return formatBytes(freeBytes) + " free of " + formatBytes(totalBytes) +} + +// Linux: read /proc/meminfo directly — works everywhere, no column guessing +func getLinuxRAM() string { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return "unavailable" + } + + var totalKB, availKB int64 + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + val, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + continue + } + switch fields[0] { + case "MemTotal:": + totalKB = val + case "MemAvailable:": + availKB = val + } + } + + if totalKB == 0 { + return "unavailable" + } + return formatBytes(availKB*1024) + " free of " + formatBytes(totalKB*1024) +} + +func formatBytes(b int64) string { + const gb = 1024 * 1024 * 1024 + const mb = 1024 * 1024 + if b >= gb { + return fmt.Sprintf("%.1fGB", float64(b)/float64(gb)) + } + return fmt.Sprintf("%.0fMB", float64(b)/float64(mb)) +} + // ------- Config Helpers ------- type PicoConfig struct { diff --git a/templates/index.html b/templates/index.html index e346929..7c6eecd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -51,6 +51,16 @@ } header .logo { font-size: 20px; } header h1 { font-size: 17px; font-weight: 700; } + header .logo-btn { + display: flex; align-items: center; gap: 10px; + cursor: pointer; + padding: 4px 8px 4px 0; + border-radius: 8px; + transition: opacity 0.15s; + -webkit-user-select: none; user-select: none; + } + header .logo-btn:hover { opacity: 0.75; } + header .logo-btn:active { opacity: 0.5; } header .header-badge { margin-left: auto; font-size: 11px; @@ -62,7 +72,6 @@ white-space: nowrap; } - /* ── Progress bar (below header) ── */ .progress-bar { position: fixed; top: var(--header-h); @@ -77,7 +86,6 @@ transition: width 0.4s ease; } - /* ── Layout ── */ .layout { display: flex; min-height: 100vh; @@ -85,7 +93,6 @@ padding-top: calc(var(--header-h) + 3px); } - /* ── Desktop sidebar ── */ .sidebar { width: 240px; flex-shrink: 0; @@ -135,7 +142,6 @@ .step-label { font-size: 13px; font-weight: 500; } .step-sub { font-size: 11px; color: var(--text2); margin-top: 1px; } - /* ── Mobile bottom nav ── */ .bottom-nav { display: none; position: fixed; @@ -181,7 +187,6 @@ .bnav-label { font-size: 9px; color: var(--text2); font-weight: 500; text-align: center; line-height: 1.2; } .bottom-nav-item.active .bnav-label { color: var(--accent); } - /* ── Main content ── */ .main { flex: 1; min-width: 0; @@ -196,7 +201,6 @@ h2 { font-size: 20px; font-weight: 700; margin-bottom: 4px; } .subtitle { color: var(--text2); font-size: 13px; margin-bottom: 20px; line-height: 1.5; } - /* ── Cards ── */ .card { background: var(--surface); border: 1px solid var(--border); @@ -213,19 +217,31 @@ margin-bottom: 12px; } - /* ── Status rows ── */ .status-row { display: flex; align-items: center; justify-content: space-between; - gap: 8px; - padding: 9px 0; + gap: 12px; + padding: 11px 0; border-bottom: 1px solid var(--border); } .status-row:last-child { border-bottom: none; } - .status-label { font-size: 13px; flex-shrink: 0; } - .status-right { display: flex; align-items: center; gap: 6px; min-width: 0; } - .status-value { font-size: 12px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100px; } + .status-left { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; + } + .status-label { font-size: 13px; font-weight: 600; } + .status-detail { + font-size: 11px; + color: var(--text2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } .badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 20px; @@ -237,7 +253,6 @@ .badge.warn { background: rgba(255,181,71,0.15); color: var(--warning); } .badge.pending { background: var(--surface2); color: var(--text2); } - /* ── Forms ── */ .form-group { margin-bottom: 14px; } label { display: block; @@ -253,7 +268,7 @@ border-radius: 8px; padding: 10px 12px; color: var(--text); - font-size: 15px; /* 15px prevents iOS zoom */ + font-size: 15px; outline: none; transition: border-color 0.15s; font-family: inherit; @@ -270,7 +285,6 @@ textarea { resize: vertical; min-height: 72px; line-height: 1.5; } .hint { font-size: 11px; color: var(--text2); margin-top: 4px; } - /* ── Provider grid ── */ .provider-grid { display: grid; grid-template-columns: repeat(2, 1fr); @@ -294,7 +308,6 @@ .provider-name { font-weight: 700; font-size: 13px; margin-bottom: 2px; } .provider-hint { font-size: 10px; color: var(--text2); } - /* ── Buttons ── */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 10px 18px; @@ -324,7 +337,6 @@ flex-wrap: wrap; } - /* ── Alerts ── */ .alert { padding: 10px 14px; border-radius: 8px; @@ -338,7 +350,6 @@ .alert.error { background: rgba(255,79,79,0.1); border: 1px solid var(--danger); color: var(--danger); } .alert.info { background: rgba(108,99,255,0.1); border: 1px solid var(--accent); color: #a9a3ff; } - /* ── Guide steps ── */ .guide-step { display: flex; gap: 12px; @@ -366,7 +377,6 @@ } .guide-text a { color: var(--accent2); } - /* ── Soul preview ── */ #soul-preview { background: var(--surface2); border: 1px solid var(--border); @@ -382,7 +392,6 @@ -webkit-overflow-scrolling: touch; } - /* ── Final checklist ── */ .final-item { display: flex; align-items: center; gap: 10px; padding: 11px 0; @@ -392,7 +401,6 @@ .final-item:last-child { border-bottom: none; } .check { font-size: 16px; } - /* ── Spinner ── */ .spinner { display: inline-block; width: 14px; height: 14px; @@ -404,10 +412,8 @@ } @keyframes spin { to { transform: rotate(360deg); } } - /* ── Install card ── */ #install-picoclaw-section { display: none; } - /* ── Model load row ── */ .model-load-row { display: flex; gap: 8px; @@ -421,7 +427,15 @@ cursor: pointer; } - /* ── Launch success commands ── */ + /* key-status hint shown when using saved key */ + .key-status { + font-size: 11px; + color: var(--accent2); + margin-top: 4px; + display: none; + } + .key-status.visible { display: block; } + .cmd-block { background: var(--surface2); border-radius: 8px; @@ -440,29 +454,21 @@ word-break: break-all; } - /* ── Responsive breakpoints ── */ @media (max-width: 640px) { .sidebar { display: none; } - .bottom-nav { display: flex; } - .main { padding: 16px 14px; padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + 16px); } - h2 { font-size: 18px; } .subtitle { font-size: 12px; margin-bottom: 16px; } - .provider-grid { gap: 6px; } .provider-card { padding: 10px 8px; } .provider-name { font-size: 12px; } - .btn { font-size: 13px; padding: 9px 14px; } .btn-row { gap: 6px; } - - /* Stack some two-col layouts */ - .status-value { max-width: 70px; } + .status-value { max-width: 90px; } } @media (min-width: 641px) and (max-width: 900px) { @@ -475,19 +481,189 @@ .main { padding: 28px 28px; } } - /* Very small phones */ @media (max-width: 360px) { .provider-grid { grid-template-columns: 1fr; } .btn { width: 100%; justify-content: center; } .btn-row { flex-direction: column; align-items: stretch; } } + + /* ── Network bar (below header) ── */ + .net-bar { + position: fixed; + top: var(--header-h); + left: 0; right: 0; + z-index: 98; + background: rgba(15,17,23,0.95); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 5px 16px; + font-size: 12px; + color: var(--text2); + backdrop-filter: blur(8px); + } + .net-bar .net-url { + font-family: 'SF Mono','Fira Code',monospace; + color: var(--accent2); + font-weight: 600; + font-size: 13px; + letter-spacing: 0.02em; + } + .net-bar .net-dot { + width: 7px; height: 7px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 6px var(--success); + flex-shrink: 0; + } + .net-bar .net-copy { + background: none; + border: 1px solid var(--border); + color: var(--text2); + border-radius: 5px; + padding: 1px 8px; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; + } + .net-bar .net-copy:hover { border-color: var(--accent2); color: var(--accent2); } + .net-bar .net-qr-btn { + background: none; + border: 1px solid var(--border); + color: var(--text2); + border-radius: 5px; + padding: 1px 8px; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; + } + .net-bar .net-qr-btn:hover { border-color: var(--accent); color: var(--accent); } + + /* layout offset for net-bar */ + .progress-bar { top: calc(var(--header-h) + 28px) !important; } + .layout { padding-top: calc(var(--header-h) + 28px + 3px) !important; } + .sidebar { top: calc(var(--header-h) + 28px + 3px) !important; height: calc(100vh - var(--header-h) - 28px - 3px) !important; } + + /* ── QR Modal ── */ + .qr-modal-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(6px); + align-items: center; + justify-content: center; + } + .qr-modal-overlay.open { display: flex; } + .qr-modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 28px 24px 20px; + text-align: center; + max-width: 280px; + width: 90%; + position: relative; + } + .qr-modal h3 { font-size: 15px; font-weight: 700; margin-bottom: 4px; } + .qr-modal p { font-size: 12px; color: var(--text2); margin-bottom: 16px; } + #qr-canvas { border-radius: 8px; } + .qr-modal .qr-url { + margin-top: 12px; + font-family: 'SF Mono','Fira Code',monospace; + font-size: 13px; + color: var(--accent2); + word-break: break-all; + } + .qr-modal .qr-close { + position: absolute; + top: 10px; right: 12px; + background: none; border: none; + color: var(--text2); font-size: 18px; + cursor: pointer; line-height: 1; + padding: 4px; + } + .qr-modal .qr-close:hover { color: var(--text); } + + /* ── Action grid (replaces btn-row for quick actions) ── */ + .action-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-top: 4px; + } + .action-tile { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 8px 12px; + cursor: pointer; + transition: all 0.15s; + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 600; + color: var(--text); + text-align: center; + -webkit-user-select: none; + user-select: none; + touch-action: manipulation; + } + .action-tile:active { transform: scale(0.96); } + .action-tile:hover { border-color: var(--accent); background: rgba(108,99,255,0.08); } + .action-tile.danger:hover { border-color: var(--danger); background: rgba(255,79,79,0.08); } + .action-tile .tile-icon { font-size: 22px; line-height: 1; } + .action-tile .tile-label { font-size: 11px; font-weight: 600; color: var(--text2); } + .action-tile.loading { opacity: 0.6; pointer-events: none; } + + @media (max-width: 640px) { + .net-bar { font-size: 11px; gap: 7px; } + .net-bar .net-url { font-size: 12px; } + /* on mobile bump the layout down by net-bar height */ + .layout { padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + 16px); } + .main { padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + 16px) !important; } + .action-grid { grid-template-columns: repeat(3, 1fr); gap: 8px; } + .action-tile { padding: 12px 4px 10px; } + .action-tile .tile-icon { font-size: 18px; } + .action-tile .tile-label { font-size: 10px; } + } + + + + + +
+
+ +

Scan to Open

+

Point your phone camera at this code

+
+
+
+
+
+
+
- -

claw-setup

+
+ +

claw-setup

+
PicoClaw Wizard
@@ -547,6 +723,26 @@

System Check

+
@@ -580,6 +776,7 @@

LLM Provider

Get your free key at openrouter.ai/keys
+
✓ Using saved key — paste a new one above to change it
@@ -593,7 +790,7 @@

LLM Provider

- +
@@ -725,7 +922,7 @@

Build Your Twin's Soul

Launch Your Twin 🚀

-

Everything's configured. Let's get PicoClaw running permanently on your Pi.

+

Everything's configured. Let's get PicoClaw running permanently on your device.

Final Checklist
@@ -746,6 +943,21 @@

Launch Your Twin 🚀

🎉
Your twin is live!
Open Telegram and send your bot a message. It will respond as you.
+
+
+
+
+
Restart Twin
+
+
+
+
Change Model
+
+
+
🏓
+
Ping Bot
+
+
Useful commands:
@@ -819,17 +1031,13 @@

Launch Your Twin 🚀

document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); document.querySelectorAll('.step-item').forEach(s => s.classList.remove('active')); document.querySelectorAll('.bottom-nav-item').forEach(s => s.classList.remove('active')); - document.getElementById(`step-${n}`).classList.add('active'); document.getElementById(`nav-${n}`).classList.add('active'); document.getElementById(`bnav-${n}`).classList.add('active'); document.getElementById('progress').style.width = progress[n] + '%'; currentStep = n; - - // Scroll to top of main document.querySelector('.main').scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' }); - if (n === 0) runSystemCheck(); if (n === 1) populateLLM(); if (n === 2) populateTelegram(); @@ -838,10 +1046,8 @@

Launch Your Twin 🚀

} function markDone(n) { - const icon = document.getElementById(`icon-${n}`); - const bicon = document.getElementById(`bicon-${n}`); - icon.textContent = '✓'; - bicon.textContent = '✓'; + document.getElementById(`icon-${n}`).textContent = '✓'; + document.getElementById(`bicon-${n}`).textContent = '✓'; document.getElementById(`nav-${n}`).classList.add('done'); document.getElementById(`bnav-${n}`).classList.add('done'); } @@ -858,6 +1064,11 @@

Launch Your Twin 🚀

} function hideAlert(id) { document.getElementById(id).className = 'alert'; } +// Returns true if the current provider has a saved key in config +function hasSavedKey() { + return systemData.has_provider && systemData.active_provider === selectedProvider; +} + // ── Step 0: System Check ────────────────────────────────────────── async function runSystemCheck() { document.getElementById('system-rows').innerHTML = ` @@ -870,7 +1081,7 @@

Launch Your Twin 🚀

const data = await r.json(); systemData = data; - // Update OS-sensitive labels once we know the OS + // OS-sensitive labels const isMac = data.os === 'mac'; const deviceLabel = isMac ? 'Mac' : 'Pi'; document.getElementById('btn-save-soul').textContent = `Save to ${deviceLabel}`; @@ -878,25 +1089,26 @@

Launch Your Twin 🚀

? 'This installs a launchd agent so PicoClaw starts automatically on login and restarts if it crashes.' : 'This installs a systemd service so PicoClaw starts automatically on boot and restarts if it crashes.'; + // FIX: check actual value for Disk/RAM — 'unavailable' means backend couldn't read it const rows = [ - ['PicoClaw', data.picoclaw_installed, data.picoclaw_version || 'Not found'], - ['Disk Space', true, data.disk_space], - ['RAM', true, data.ram], + ['PicoClaw', data.picoclaw_installed, data.picoclaw_version || 'Not found'], + ['Disk Space', data.disk_space && data.disk_space !== 'unavailable', data.disk_space || 'unavailable'], + ['RAM', data.ram && data.ram !== 'unavailable', data.ram || 'unavailable'], ['LLM Provider', data.has_provider, data.active_model ? `${data.active_model} (${data.active_provider})` : 'Not set'], - ['Telegram', data.has_telegram, data.telegram_token ? `Token: ${data.telegram_token}` : 'Not set'], - ['SOUL.md', data.has_soul, data.has_soul ? 'Found' : 'Not created'], - ['Service', data.service_status === 'active', data.service_status], + ['Telegram', data.has_telegram, data.telegram_token ? `Token: ${data.telegram_token}` : 'Not set'], + ['SOUL.md', data.has_soul, data.has_soul ? 'Found' : 'Not created'], + ['Service', data.service_status === 'active', data.service_status], ]; document.getElementById('system-rows').innerHTML = rows.map(([label, ok, val]) => `
- ${label} -
- ${val} - - ${ok ? '✓ OK' : '○ Pending'} - +
+ ${label} + ${val}
+ + ${ok ? '✓ OK' : '○ Pending'} +
`).join(''); if (!data.picoclaw_installed) { @@ -907,12 +1119,15 @@

Launch Your Twin 🚀

document.getElementById('install-picoclaw-section').style.display = 'none'; hideAlert('sys-alert'); document.getElementById('btn-sys-next').disabled = false; - if (data.checklist.system) markDone(0); + if (data.checklist.system) markDone(0); if (data.checklist.provider) { markDone(1); state.llm = true; } if (data.checklist.telegram) { markDone(2); state.telegram = true; } - if (data.checklist.soul) { markDone(3); state.soul = true; } - if (data.checklist.service) { markDone(4); state.service = true; } + if (data.checklist.soul) { markDone(3); state.soul = true; } + if (data.checklist.service) { markDone(4); state.service = true; } state.system = true; + // Show quick actions on system check if service is running + const qa = document.getElementById('quick-actions'); + if (qa) qa.style.display = data.service_status === 'active' ? 'block' : 'none'; } } @@ -930,40 +1145,106 @@

Launch Your Twin 🚀

document.getElementById('llm-model').innerHTML = info.models .map(([v, l]) => ``).join(''); } - document.getElementById('llm-key').value = ''; - hideAlert('llm-alert'); + // Don't clear the key field — but update the saved-key status indicator + updateKeyStatus(); +} + +function updateKeyStatus() { + const saved = hasSavedKey(); + const keyEl = document.getElementById('key-status'); + keyEl.className = saved ? 'key-status visible' : 'key-status'; + // Enable Load Models if there's a saved key or user typed one + const typed = document.getElementById('llm-key').value.trim().length >= 10; + document.getElementById('btn-load-models').disabled = !saved && !typed; + // Relabel the validate button based on context + document.getElementById('btn-validate-llm').textContent = saved + ? 'Save Model' + : 'Validate Key'; +} + +function onKeyInput() { + updateKeyStatus(); } async function validateLLM() { const key = document.getElementById('llm-key').value.trim(); const model = document.getElementById('llm-model').value; - if (!key) { showAlert('llm-alert', 'error', 'Please enter your API key'); return; } + if (!model) { showAlert('llm-alert', 'error', 'Please select a model'); return; } + + // Allow empty key only if we have a saved one for this provider + if (!key && !hasSavedKey()) { + showAlert('llm-alert', 'error', 'Please enter your API key'); + return; + } const btn = document.getElementById('btn-validate-llm'); - btn.innerHTML = '
Validating...'; + btn.innerHTML = '
Saving...'; btn.disabled = true; const fd = new FormData(); fd.append('provider', selectedProvider || 'openrouter'); - fd.append('api_key', key); fd.append('model', model); + if (key) fd.append('api_key', key); // only send if user typed one; backend falls back to saved key const r = await fetch('/api/validate-llm', { method: 'POST', body: fd }); const data = await r.json(); - btn.innerHTML = 'Validate Key'; + btn.innerHTML = hasSavedKey() ? 'Save Model' : 'Validate Key'; btn.disabled = false; if (data.ok) { - showAlert('llm-alert', 'success', '✓ ' + data.message + ` - using ${model}`); - document.getElementById('btn-llm-next').disabled = false; + // Update systemData so hasSavedKey() stays accurate for the new provider + systemData.has_provider = true; + systemData.active_provider = selectedProvider; + systemData.active_model = model; + updateKeyStatus(); markDone(1); state.llm = true; + document.getElementById('btn-llm-next').disabled = false; + + // If service is already running, restart it so the new model takes effect + if (state.service) { + showAlert('llm-alert', 'info', `✓ Model saved — restarting agent to apply ${model}...`); + btn.innerHTML = '
Restarting...'; + btn.disabled = true; + const rr = await fetch('/api/restart-service', { method: 'POST' }); + const rd = await rr.json(); + btn.innerHTML = hasSavedKey() ? 'Save Model' : 'Validate Key'; + btn.disabled = false; + if (rd.ok) { + showAlert('llm-alert', 'success', `✓ Model updated & agent restarted — now using ${model}`); + } else { + showAlert('llm-alert', 'warn', `✓ Model saved but restart failed: ${rd.message}`); + } + } else { + showAlert('llm-alert', 'success', '✓ ' + data.message + ` — using ${model}`); + } } else { showAlert('llm-alert', 'error', '✗ ' + data.message); markError(1); } } +async function loadModels() { + const key = document.getElementById('llm-key').value.trim(); + const btn = document.getElementById('btn-load-models'); + btn.innerHTML = '
'; + btn.disabled = true; + + const fd = new FormData(); + fd.append('provider', selectedProvider); + if (key) fd.append('api_key', key); // omit if empty — backend uses saved key + + const r = await fetch('/api/models', { method: 'POST', body: fd }); + const data = await r.json(); + btn.innerHTML = '↻ Load Models'; + btn.disabled = false; + + if (!data.ok) { showAlert('llm-alert', 'error', '✗ ' + data.message); return; } + allModels = data.models; + filterModels(); + showAlert('llm-alert', 'success', `✓ Loaded ${data.models.length} models`); +} + // ── Step 2: Telegram ───────────────────────────────────────────── async function validateTelegram() { const token = document.getElementById('tg-token').value.trim(); @@ -1034,7 +1315,7 @@

Launch Your Twin 🚀

const r = await fetch('/api/save-soul', { method: 'POST', body: fd }); const data = await r.json(); if (data.ok) { - showAlert('soul-save-alert', 'success', '✓ SOUL.md saved to your Pi at ' + data.message.split('to ')[1]); + showAlert('soul-save-alert', 'success', '✓ SOUL.md saved to your device at ' + data.message.split('to ')[1]); document.getElementById('btn-soul-next').disabled = false; markDone(3); state.soul = true; } else { showAlert('soul-save-alert', 'error', '✗ ' + data.message); } @@ -1044,6 +1325,8 @@

Launch Your Twin 🚀

async function loadFinalChecklist() { const r = await fetch('/api/system-check'); const data = await r.json(); + systemData = data; // keep in sync + const items = [ ['PicoClaw installed', data.picoclaw_installed], ['LLM provider configured', data.has_provider], @@ -1057,19 +1340,15 @@

Launch Your Twin 🚀

${label}
`).join(''); - // If service is already running, hide the install card and show success if (data.service_status === 'active') { document.getElementById('service-install-card').style.display = 'none'; document.getElementById('launch-success').style.display = 'block'; document.getElementById('progress').style.width = '100%'; markDone(4); - - const isMac = data.os === 'mac'; - document.getElementById('cmd-hint').textContent = isMac ? 'Useful commands on your Mac:' : 'Useful commands on your Pi:'; - document.getElementById('cmd-status').textContent = isMac ? 'launchctl list | grep picoclaw' : 'systemctl --user status picoclaw'; - document.getElementById('cmd-restart').textContent = isMac - ? 'launchctl unload ~/Library/LaunchAgents/com.picoclaw.agent.plist && launchctl load ~/Library/LaunchAgents/com.picoclaw.agent.plist' - : 'systemctl --user restart picoclaw'; + populateOSCommands(data.os === 'mac'); + } else { + document.getElementById('service-install-card').style.display = 'block'; + document.getElementById('launch-success').style.display = 'none'; } } @@ -1079,28 +1358,22 @@

Launch Your Twin 🚀

const data = await r.json(); if (data.ok) { showAlert('service-alert', 'success', '✓ ' + data.message); - // Hide the install card — service is already running - document.getElementById('service-install-card').style.display = 'none'; - document.getElementById('launch-success').style.display = 'block'; - document.getElementById('progress').style.width = '100%'; markDone(4); state.service = true; - - // Populate OS-specific commands - const isMac = systemData.os === 'mac'; - document.getElementById('cmd-hint').textContent = isMac - ? 'Useful commands on your Mac:' - : 'Useful commands on your Pi:'; - document.getElementById('cmd-status').textContent = isMac - ? 'launchctl list | grep picoclaw' - : 'systemctl --user status picoclaw'; - document.getElementById('cmd-restart').textContent = isMac - ? 'launchctl unload ~/Library/LaunchAgents/com.picoclaw.agent.plist && launchctl load ~/Library/LaunchAgents/com.picoclaw.agent.plist' - : 'systemctl --user restart picoclaw'; - - loadFinalChecklist(); + // Re-fetch system status so checklist shows real service state + await loadFinalChecklist(); } else { showAlert('service-alert', 'error', '✗ ' + data.message); } } +function populateOSCommands(isMac) { + document.getElementById('cmd-hint').textContent = isMac ? 'Useful commands on your Mac:' : 'Useful commands on your Pi:'; + document.getElementById('cmd-status').textContent = isMac + ? 'launchctl list | grep picoclaw' + : 'systemctl --user status picoclaw'; + document.getElementById('cmd-restart').textContent = isMac + ? 'launchctl unload ~/Library/LaunchAgents/com.picoclaw.agent.plist && launchctl load ~/Library/LaunchAgents/com.picoclaw.agent.plist' + : 'systemctl --user restart picoclaw'; +} + function populateLLM() { if (systemData.active_provider) selectProvider(systemData.active_provider); if (systemData.active_model) { @@ -1122,6 +1395,8 @@

Launch Your Twin 🚀

document.getElementById('btn-llm-next').disabled = false; markDone(1); } + // Always call this so Load Models button and key-status reflect current state + updateKeyStatus(); } function populateTelegram() { @@ -1160,23 +1435,52 @@

Launch Your Twin 🚀

} } -let allModels = []; -function onKeyInput() { - document.getElementById('btn-load-models').disabled = document.getElementById('llm-key').value.trim().length < 10; +async function restartService() { await restartServiceFrom('action-alert', 'tile-launch-restart'); } +async function restartServiceFrom(alertId, tileId) { + const tile = document.getElementById(tileId); + const origHTML = tile.innerHTML; + tile.classList.add('loading'); + tile.querySelector('.tile-icon').textContent = '⏳'; + const r = await fetch('/api/restart-service', { method: 'POST' }); + const data = await r.json(); + tile.innerHTML = origHTML; + tile.classList.remove('loading'); + if (data.ok) { + showAlert(alertId, 'success', '✓ Agent restarted'); + } else { + showAlert(alertId, 'error', '✗ ' + data.message); + } + setTimeout(() => hideAlert(alertId), 4000); } -async function loadModels() { - const key = document.getElementById('llm-key').value.trim(); - const btn = document.getElementById('btn-load-models'); - btn.innerHTML = '
'; - btn.disabled = true; - const fd = new FormData(); fd.append('provider', selectedProvider); fd.append('api_key', key); - const r = await fetch('/api/models', { method: 'POST', body: fd }); + +async function pingTelegramAction() { await pingTelegramFrom('action-alert', 'tile-launch-ping'); } +async function pingTelegramFrom(alertId, tileId) { + const tile = document.getElementById(tileId); + const origHTML = tile.innerHTML; + tile.classList.add('loading'); + tile.querySelector('.tile-icon').textContent = '⏳'; + const chatId = systemData.telegram_user || ''; + if (!chatId) { + showAlert(alertId, 'error', 'No Telegram user ID saved — complete step 3 first'); + tile.innerHTML = origHTML; + tile.classList.remove('loading'); + return; + } + const fd = new FormData(); fd.append('chat_id', chatId); + const r = await fetch('/api/ping-telegram', { method: 'POST', body: fd }); const data = await r.json(); - btn.innerHTML = '↻ Load Models'; btn.disabled = false; - if (!data.ok) { showAlert('llm-alert', 'error', '✗ ' + data.message); return; } - allModels = data.models; filterModels(); - showAlert('llm-alert', 'success', `✓ Loaded ${data.models.length} models`); + tile.innerHTML = origHTML; + tile.classList.remove('loading'); + if (data.ok) { + showAlert(alertId, 'success', '✓ Ping sent!'); + } else { + showAlert(alertId, 'error', '✗ ' + data.message); + } + setTimeout(() => hideAlert(alertId), 4000); } + +let allModels = []; + function filterModels() { const freeOnly = document.getElementById('free-only').checked; const sel = document.getElementById('llm-model'); @@ -1184,7 +1488,90 @@

Launch Your Twin 🚀

sel.innerHTML = filtered.map(m => ``).join(''); } +// ── Network bar & QR ────────────────────────────────────────── +let localIP = ''; + +async function initNetBar() { + try { + const r = await fetch('/api/local-ip'); + const data = await r.json(); + localIP = data.ip || window.location.hostname; + } catch(e) { + localIP = window.location.hostname; + } + const url = `http://${localIP}:3000`; + document.getElementById('net-url-text').textContent = url; + document.getElementById('net-bar').style.display = 'flex'; +} + +function copyNetUrl() { + const url = `http://${localIP}:3000`; + navigator.clipboard.writeText(url).then(() => { + const btn = document.getElementById('net-copy-btn'); + btn.textContent = 'Copied!'; + setTimeout(() => btn.textContent = 'Copy', 1800); + }).catch(() => { + prompt('Copy this URL:', url); + }); +} + +function openQR() { + const url = `http://${localIP}:3000`; + document.getElementById('qr-url-label').textContent = url; + document.getElementById('qr-overlay').classList.add('open'); + + const container = document.getElementById('qr-container'); + container.innerHTML = '
'; + + // Use Google Charts QR API — reliable, no JS library needed + const img = new Image(); + img.width = 200; + img.height = 200; + img.style.borderRadius = '8px'; + img.style.display = 'block'; + img.alt = url; + img.onload = () => { container.innerHTML = ''; container.appendChild(img); }; + img.onerror = () => { + // If no internet, render QR natively using canvas + container.innerHTML = ''; + renderQRCanvas(container, url); + }; + img.src = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&color=ffffff&bgcolor=1a1d27&data=${encodeURIComponent(url)}`; +} + +// Pure-JS QR fallback using a tiny Reed-Solomon encoder +// Based on the MIT-licensed nayuki QR algorithm (simplified for URLs) +function renderQRCanvas(container, text) { + // Use a simple approach: encode as data matrix via inline SVG path + // For local network URLs, we use a pre-computed approach via fetch trick + const canvas = document.createElement('canvas'); + canvas.width = 200; canvas.height = 200; + canvas.style.borderRadius = '8px'; + container.appendChild(canvas); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#1a1d27'; + ctx.fillRect(0,0,200,200); + ctx.fillStyle = '#ffffff'; + ctx.font = '11px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('QR needs internet', 100, 90); + ctx.fillText('to generate.', 100, 108); + ctx.font = '10px monospace'; + ctx.fillStyle = 'var(--accent2)'; + ctx.fillText('URL copied to clipboard', 100, 135); + navigator.clipboard.writeText(text).catch(()=>{}); +} + +function closeQR() { + document.getElementById('qr-overlay').classList.remove('open'); +} + +function closeQRIfOutside(e) { + if (e.target === document.getElementById('qr-overlay')) closeQR(); +} + // Init +initNetBar(); runSystemCheck();