Files
picoclaw/web/backend/api/gateway.go
T
wenjie e55b3b7a8d feat(web): migrate launcher to modular web frontend/backend and improve management UX (#1275)
* refactor: remove the legacy picoclaw-launcher

* feat: create initial web frontend and backend structure

* feat(packaging): add desktop entry for PicoClaw Launcher (#1062)

- Add .desktop file with Terminal=true, named "PicoClaw Launcher"
- Install to /usr/share/applications/ for app menu visibility
- Add 512x512 PNG icon to /usr/share/icons/hicolor/

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* `make dev`: If you haven't built it before, you need to run `build` first.

* feat(web): comprehensive web UI and backend refactoring
This commit introduces a major overhaul of both the frontend web UI and the Go backend API, transitioning to a highly modular architecture and integrating new core features.
Backend:
- Refactored monolithic API endpoints into domain-specific modules (config, gateway, log, models, pico, session).
- Cleaned up obsolete files (`server.go`, `status.go`, WebSocket handlers) and outdated tests.
- Implemented Gateway process lifecycle management (start/stop/restart) and real-time log streaming.
Frontend:
- Integrated Shadcn UI components to establish a modern, consistent design system.
- Introduced a new application layout featuring a responsive sidebar (`app-sidebar`) and header.
- Implemented internationalization (i18n) with initial support for English and Chinese.
- Restructured API clients, hooks, and Zustand stores into logical domains.
- Added new management pages for Settings, Logs, Models, Providers, and Credentials.
- Upgraded the Pico chat interface with session history management and dynamic model selection.
Build & Config:
- Updated frontend dependencies, Vite configuration, and lockfiles.
- Refined routing setup and overarching application stylesheets.

* feat(web): enhance model management, sorting, and deletion logic
- Implement model sorting in UI (default > configured > unconfigured)
- Prevent deletion of default models in the frontend
- Update backend to clear default settings when a model is deleted
- Add existence validation when setting a default model via API
- Group models in chat UI by type (API Key, OAuth, Local)
- Conditionally display model selector in chat based on configuration status

* refactor(web): refactor chat page into modular components/hooks and update i18n

- split chat route into dedicated chat components (page, composer, empty state, messages, history, model selector)
- extract model/session logic into use-chat-models and use-session-history hooks
- update chat locale keys in en/zh and add empty-state/history-related translations

* refactor(models): refactor models page into modular components and improve UX

- split /models route into dedicated components (page, provider section, card, add/edit sheets, delete dialog)
- add provider grouping/sorting, provider labels/icons, and a no-default hint in the models page
- add "Set as default model" toggle to add/edit flows with safer defaults
- introduce shared form helpers and new UI primitives (field, label, switch)
- update i18n strings (en/zh) for models and gateway header text usage
- apply minor UI polish (models nav icon, separator client directive)

* fix(web): add SPA index fallback for embedded frontend routes

Serve existing static assets as-is, keep /api/* and missing asset paths returning 404, and add tests for SPA fallback behavior on refresh.

* fix(frontend/chat): normalize message timestamp units to prevent invalid far-future dates

* chore: delete TestSPARouteFallsBackToIndex

* feat: update build for web-based launcher (#1186)

- Makefile: add build-launcher target (builds frontend + Go backend)
- GoReleaser: point picoclaw-launcher build to web/backend, add frontend
  build hook, restore winres hook with updated paths
- Restore icon.ico and winres config from main for Windows builds

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(credentials): add multi-provider OAuth credential management

- add backend `/api/oauth/*` endpoints for provider status, browser/device-code/token login, flow query/polling, and logout
- extend API handler with OAuth flow/state tracking and route registration, plus OAuth unit tests
- implement frontend credentials page/components for OpenAI, Anthropic, and Google Antigravity login/logout
- add OAuth API client and `useCredentialsPage` hook, with new EN/ZH i18n strings

* chore: remove placeholder index.html from dist (#1188)

The .gitkeep is sufficient for go:embed to find the dist directory.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): polish model and credential UX; remove Providers nav

- remove the Providers item from sidebar navigation and locale keys
- simplify chat composer by dropping attach/voice action buttons
- support ReactNode titles in credential cards and add provider brand icons
- refine sheet header/footer styling and device-code footer button hierarchy
- disable “Set default” when a model is unconfigured or already default

* feat(web): Update  config page (#1173)

* feat(web): Update  config page

* fix(web): useEffect resets editorValue whenever config changes

* fix(web): react-hooks/set-state-in-effect error & pnpm lint #1173

* feat(web): add channel management page for web console (#1190)

* feat(web): add channel management page for web console

Add a complete channel management UI that allows users to configure
messaging channels (Telegram, Discord, Slack, Feishu, etc.) directly
from the web console instead of manually editing config.json.

Backend: GET/PUT/PATCH API endpoints for listing, updating, and
toggling channels with secret field masking.

Frontend: Channel cards grid with enable/disable toggles, per-channel
configuration sheets with dedicated forms for major platforms and a
generic fallback for others.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web/channels): move channels to own sidebar group and fix sheet padding

- Channels now has its own navigation group instead of being under Services
- Fix edit sheet form content padding (px-1 -> px-4) to match header/footer
- Fix naked return lint error in extractChannelInfo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): harden channel config updates and resolve frontend lint issues

- validate channel PUT/PATCH updates before saving and return structured validation errors
- require `enabled` in toggle requests to avoid silent false defaults
- support editing `allow_origins` in the generic channel form and parse string/array inputs on backend
- replace channel form `any` usage with `ChannelConfig` (`Record<string, unknown>`) and add safe value helpers
- add i18n strings for allow-origins fields and apply related frontend formatting cleanups

* fix(frontend): prevent false "Invalid JSON" errors in config editor

* feat: add startup readiness checks and propagate start availability to UI

- add gateway precondition validation for default model and credentials
- auto-start gateway on backend boot when conditions are met
- include gateway_start_allowed and gateway_start_reason in status updates
- prevent frontend start actions when gateway cannot be started

* feat(web): revamp channel config UX with catalog-based routing

- replace legacy channel management endpoints with a backend channel catalog API
- switch frontend channel updates to PATCH /api/config and per-channel config pages
- add dynamic channel items in the sidebar with support for expand/collapse
- migrate /channels to nested routes (/channels/$name) and remove old card/sheet flow
- improve channel forms with clearer hints, required/error states, and reusable switch cards
- fix Discord mention-only toggle to read/write group_trigger.mention_only

* refactor(frontend): move shared-form to components and unify default-model switch with SwitchCardField

* fix(frontend): improve model form validation and unify secret placeholder handling

- block duplicate model aliases when adding a model (with localized error messages)
- share masked secret placeholder logic across model and channel forms
- refresh gateway state after setting the default model
- apply minor UI cleanup to provider icon rendering

* feat(web): add visual system config and launcher/autostart controls

- add launcher config model and persistence (`launcher-config.json`) for port/public/CIDR settings
- add system APIs for launch-at-login and launcher parameters
- apply CIDR-based access-control middleware to backend HTTP routes
- split config routing into visual config and raw JSON config pages
- add frontend system API client and visual config sections for runtime/devices/launcher
- expand i18n strings (en/zh) for new config UI
- improve sidebar active matching and session ID generation fallback

* refactor(frontend): remove i18n fallback strings and drop providers route

- Replace `t(key, defaultValue)` calls with key-only translations across UI pages
- Clean up locale files by pruning unused keys and adding missing shared keys
- Remove the obsolete `/providers` page and update generated route tree

* fix(backend): correct gateway status detection on Windows

* fix(repo): keep web backend dist placeholder tracked

---------

Co-authored-by: Guoguo <16666742+imguoguo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dihubopen <dihubcn@gmail.com>
Co-authored-by: Dihubopen <130813726+Dihubopen@users.noreply.github.com>
2026-03-09 19:42:03 +08:00

556 lines
14 KiB
Go

package api
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// gateway holds the state for the managed gateway process.
var gateway = struct {
mu sync.Mutex
cmd *exec.Cmd
logs *LogBuffer
events *EventBroadcaster
}{
logs: NewLogBuffer(200),
events: NewEventBroadcaster(),
}
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
}
// TryAutoStartGateway checks whether gateway start preconditions are met and
// starts it when possible. Intended to be called by the backend at startup.
func (h *Handler) TryAutoStartGateway() {
gateway.mu.Lock()
defer gateway.mu.Unlock()
if isGatewayProcessAliveLocked() {
return
}
if gateway.cmd != nil && gateway.cmd.Process != nil {
gateway.cmd = nil
}
ready, reason, err := h.gatewayStartReady()
if err != nil {
log.Printf("Skip auto-starting gateway: %v", err)
return
}
if !ready {
log.Printf("Skip auto-starting gateway: %s", reason)
return
}
pid, err := h.startGatewayLocked()
if err != nil {
log.Printf("Failed to auto-start gateway: %v", err)
return
}
log.Printf("Gateway auto-started (PID: %d)", pid)
}
// gatewayStartReady validates whether current config can start the gateway.
func (h *Handler) gatewayStartReady() (bool, string, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return false, "", fmt.Errorf("failed to load config: %w", err)
}
modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
if modelName == "" {
return false, "no default model configured", nil
}
modelCfg := lookupModelConfig(cfg, modelName)
if modelCfg == nil {
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
}
hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" ||
strings.TrimSpace(modelCfg.AuthMethod) != ""
if !hasCredential {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
}
return true, "", nil
}
func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig {
modelCfg, err := cfg.GetModelConfig(modelName)
if err != nil {
return nil
}
return modelCfg
}
func isGatewayProcessAliveLocked() bool {
return isCmdProcessAliveLocked(gateway.cmd)
}
func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
if cmd == nil || cmd.Process == nil {
return false
}
// Wait() sets ProcessState when the process exits; use it when available.
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
return false
}
// Windows does not support Signal(0) probing. If we still own cmd and it
// has not reported exit, treat it as alive.
if runtime.GOOS == "windows" {
return true
}
return cmd.Process.Signal(syscall.Signal(0)) == nil
}
func (h *Handler) startGatewayLocked() (int, error) {
// Locate the picoclaw executable
execPath := findPicoclawBinary()
cmd := exec.Command(execPath, "gateway")
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return 0, fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return 0, fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Clear old logs for this new run
gateway.logs.Reset()
// Ensure Pico Channel is configured before starting gateway
if _, err := h.ensurePicoChannel(); err != nil {
log.Printf("Warning: failed to ensure pico channel: %v", err)
// Non-fatal: gateway can still start without pico channel
}
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("failed to start gateway: %w", err)
}
gateway.cmd = cmd
pid := cmd.Process.Pid
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
// Broadcast starting event
gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid})
// Capture stdout/stderr in background
go scanPipe(stdoutPipe, gateway.logs)
go scanPipe(stderrPipe, gateway.logs)
// Wait for exit in background and clean up
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("Gateway process exited: %v", err)
} else {
log.Printf("Gateway process exited normally")
}
gateway.mu.Lock()
if gateway.cmd == cmd {
gateway.cmd = nil
}
gateway.mu.Unlock()
// Broadcast stopped event
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
}()
// Start a goroutine to probe health and broadcast "running" once ready
go func() {
for i := 0; i < 30; i++ { // try for up to 15 seconds
time.Sleep(500 * time.Millisecond)
gateway.mu.Lock()
stillOurs := gateway.cmd == cmd
gateway.mu.Unlock()
if !stillOurs {
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
continue
}
healthHost := "127.0.0.1"
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
healthHost = cfg.Gateway.Host
}
healthPort := cfg.Gateway.Port
if healthPort == 0 {
healthPort = 18790
}
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
client := http.Client{Timeout: 1 * time.Second}
resp, err := client.Get(healthURL)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
gateway.events.Broadcast(GatewayEvent{Status: "running", PID: pid})
return
}
}
}
}()
return pid, nil
}
// handleGatewayStart starts the picoclaw gateway subprocess.
//
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
// Prevent duplicate starts
if isGatewayProcessAliveLocked() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]any{
"status": "already_running",
"pid": gateway.cmd.Process.Pid,
})
return
}
if gateway.cmd != nil && gateway.cmd.Process != nil {
gateway.cmd = nil
}
ready, reason, err := h.gatewayStartReady()
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
http.StatusInternalServerError,
)
return
}
if !ready {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "precondition_failed",
"message": reason,
})
return
}
pid, err := h.startGatewayLocked()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": pid,
})
}
// handleGatewayStop stops the running gateway subprocess gracefully.
//
// POST /api/gateway/stop
func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
if gateway.cmd == nil || gateway.cmd.Process == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "not_running",
})
return
}
pid := gateway.cmd.Process.Pid
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
if runtime.GOOS == "windows" {
sigErr = gateway.cmd.Process.Kill()
} else {
sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM)
}
if sigErr != nil {
http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, sigErr), http.StatusInternalServerError)
return
}
log.Printf("Sent stop signal to gateway (PID: %d)", pid)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": pid,
})
}
// handleGatewayRestart stops the gateway (if running) and starts a new instance.
//
// POST /api/gateway/restart
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
// Stop existing process if running
if gateway.cmd != nil && gateway.cmd.Process != nil {
if isCmdProcessAliveLocked(gateway.cmd) {
// Process is alive, send SIGTERM
if runtime.GOOS == "windows" {
gateway.cmd.Process.Kill()
} else {
gateway.cmd.Process.Signal(syscall.SIGTERM)
}
// Wait briefly for it to exit
gateway.mu.Unlock()
time.Sleep(2 * time.Second)
gateway.mu.Lock()
}
gateway.cmd = nil
}
gateway.mu.Unlock()
// Start fresh via the existing handler
h.handleGatewayStart(w, r)
}
// handleGatewayStatus returns the gateway run status, health info, and logs.
//
// GET /api/gateway/status
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
data := map[string]any{}
// Check process state
gateway.mu.Lock()
processAlive := isGatewayProcessAliveLocked()
if processAlive {
data["pid"] = gateway.cmd.Process.Pid
}
gateway.mu.Unlock()
if !processAlive {
data["gateway_status"] = "stopped"
} else {
// Process is alive — probe its health endpoint
cfg, err := config.LoadConfig(h.configPath)
host := "127.0.0.1"
port := 18790
if err == nil && cfg != nil {
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
host = cfg.Gateway.Host
}
if cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port
}
}
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
client := http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url)
if err != nil {
data["gateway_status"] = "starting"
} else {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data["gateway_status"] = "error"
data["status_code"] = resp.StatusCode
} else {
var healthData map[string]any
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
data["gateway_status"] = "error"
} else {
for k, v := range healthData {
data[k] = v
}
data["gateway_status"] = "running"
}
}
}
}
ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil {
data["gateway_start_allowed"] = false
data["gateway_start_reason"] = readyErr.Error()
} else {
data["gateway_start_allowed"] = ready
if !ready {
data["gateway_start_reason"] = reason
}
}
// Append incremental log data
appendGatewayLogs(r, data)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// appendGatewayLogs reads log_offset and log_run_id query params from the request
// and populates the response data map with incremental log lines.
func appendGatewayLogs(r *http.Request, data map[string]any) {
clientOffset := 0
clientRunID := -1
if v := r.URL.Query().Get("log_offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
clientOffset = n
}
}
if v := r.URL.Query().Get("log_run_id"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
clientRunID = n
}
}
runID := gateway.logs.RunID()
if runID == 0 {
data["logs"] = []string{}
data["log_total"] = 0
data["log_run_id"] = 0
return
}
// If runID changed, reset offset to get all logs from new run
offset := clientOffset
if clientRunID != runID {
offset = 0
}
lines, total, runID := gateway.logs.LinesSince(offset)
if lines == nil {
lines = []string{}
}
data["logs"] = lines
data["log_total"] = total
data["log_run_id"] = runID
}
// handleGatewayEvents serves an SSE stream of gateway state change events.
//
// GET /api/gateway/events
func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Subscribe to gateway events
ch := gateway.events.Subscribe()
defer gateway.events.Unsubscribe(ch)
// Send initial status so the client doesn't start blank
initial := h.currentGatewayStatus()
fmt.Fprintf(w, "data: %s\n\n", initial)
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case data, ok := <-ch:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
// currentGatewayStatus returns the current gateway status as a JSON string.
func (h *Handler) currentGatewayStatus() string {
gateway.mu.Lock()
defer gateway.mu.Unlock()
data := map[string]any{
"gateway_status": "stopped",
}
if isGatewayProcessAliveLocked() {
data["gateway_status"] = "running"
data["pid"] = gateway.cmd.Process.Pid
}
ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil {
data["gateway_start_allowed"] = false
data["gateway_start_reason"] = readyErr.Error()
} else {
data["gateway_start_allowed"] = ready
if !ready {
data["gateway_start_reason"] = reason
}
}
encoded, _ := json.Marshal(data)
return string(encoded)
}
// findPicoclawBinary locates the picoclaw executable.
// Tries the same directory as the current executable first, then falls back to $PATH.
func findPicoclawBinary() string {
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidate := filepath.Join(dir, "picoclaw")
if runtime.GOOS == "windows" {
candidate += ".exe"
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return "picoclaw"
}
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
func scanPipe(r io.Reader, buf *LogBuffer) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
buf.Append(scanner.Text())
}
}