mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
add systray ui for all platform (#1649)
* add systray ui for all platform * update from getlantern/systray to fyne.io/systray for fix test
This commit is contained in:
+58
-4
@@ -1,5 +1,59 @@
|
||||
.PHONY: dev dev-frontend dev-backend build test lint clean
|
||||
|
||||
# Version
|
||||
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||
BUILD_TIME=$(shell date +%FT%T%z)
|
||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
GOFLAGS?=-v -tags stdjson
|
||||
|
||||
|
||||
# OS detection
|
||||
UNAME_S:=$(shell uname -s)
|
||||
UNAME_M:=$(shell uname -m)
|
||||
|
||||
# Platform-specific settings
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
PLATFORM=linux
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),aarch64)
|
||||
ARCH=arm64
|
||||
else ifeq ($(UNAME_M),armv81)
|
||||
ARCH=arm64
|
||||
else ifeq ($(UNAME_M),loongarch64)
|
||||
ARCH=loong64
|
||||
else ifeq ($(UNAME_M),riscv64)
|
||||
ARCH=riscv64
|
||||
else ifeq ($(UNAME_M),mipsel)
|
||||
ARCH=mipsle
|
||||
else
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
PLATFORM=darwin
|
||||
GO=CGO_ENABLED=1 go
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
ARCH=arm64
|
||||
else
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
else ifeq ($(UNAME_S),Windows)
|
||||
PLATFORM=windows
|
||||
ARCH=$(UNAME_M)
|
||||
LDFLAGS=-H=windowsgui $(LDFLAGS)
|
||||
else
|
||||
PLATFORM=$(UNAME_S)
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
|
||||
# Run both frontend and backend dev servers
|
||||
dev:
|
||||
@if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \
|
||||
@@ -15,21 +69,21 @@ dev-frontend:
|
||||
|
||||
# Start backend dev server
|
||||
dev-backend:
|
||||
cd backend && go run .
|
||||
cd backend && ${GO} run -ldflags "$(LDFLAGS)" .
|
||||
|
||||
# Build frontend and embed into Go binary
|
||||
build:
|
||||
cd frontend && pnpm build:backend
|
||||
cd backend && go build -o picoclaw-web .
|
||||
cd backend && ${GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o picoclaw-web .
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
cd backend && go test ./...
|
||||
cd backend && ${GO} test ./...
|
||||
cd frontend && pnpm lint
|
||||
|
||||
# Lint and format
|
||||
lint:
|
||||
cd backend && go vet ./...
|
||||
cd backend && ${GO} vet ./...
|
||||
cd frontend && pnpm check
|
||||
|
||||
# Clean build artifacts
|
||||
|
||||
@@ -40,9 +40,13 @@ func (b *EventBroadcaster) Subscribe() chan string {
|
||||
// Unsubscribe removes a listener channel and closes it.
|
||||
func (b *EventBroadcaster) Unsubscribe(ch chan string) {
|
||||
b.mu.Lock()
|
||||
delete(b.clients, ch)
|
||||
b.mu.Unlock()
|
||||
close(ch)
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Check if the channel is still registered before closing
|
||||
if _, exists := b.clients[ch]; exists {
|
||||
delete(b.clients, ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast sends a GatewayEvent to all connected SSE clients.
|
||||
@@ -63,3 +67,14 @@ func (b *EventBroadcaster) Broadcast(event GatewayEvent) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown closes all subscriber channels, notifying all SSE clients to disconnect.
|
||||
// This should be called when the server is shutting down.
|
||||
func (b *EventBroadcaster) Shutdown() {
|
||||
// Close all channels to notify listeners
|
||||
for ch := range b.clients {
|
||||
b.Unsubscribe(ch)
|
||||
}
|
||||
// Clear the map
|
||||
b.clients = make(map[chan string]struct{})
|
||||
}
|
||||
|
||||
+248
-125
@@ -3,10 +3,10 @@ package api
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/health"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,27 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
|
||||
return client.Get(url)
|
||||
}
|
||||
|
||||
// getGatewayHealth checks the gateway health endpoint and returns the status response
|
||||
// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid.
|
||||
func getGatewayHealth(port int, timeout time.Duration) (*health.StatusResponse, int, error) {
|
||||
if port == 0 {
|
||||
port = 18790
|
||||
}
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/health", port)
|
||||
resp, err := gatewayHealthGet(url, timeout)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var healthResponse health.StatusResponse
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthResponse); decErr != nil {
|
||||
return nil, resp.StatusCode, decErr
|
||||
}
|
||||
|
||||
return &healthResponse, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
|
||||
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
||||
@@ -62,12 +84,35 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||
// 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() {
|
||||
// Check if gateway is already running via health endpoint
|
||||
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||
if cfgErr == nil && cfg != nil {
|
||||
healthResp, statusCode, err := getGatewayHealth(cfg.Gateway.Port, 2*time.Second)
|
||||
if err == nil && statusCode == http.StatusOK {
|
||||
// Gateway is already running, attach to the existing process
|
||||
pid := healthResp.Pid
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
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
|
||||
}
|
||||
_, err = h.startGatewayLocked("starting", pid)
|
||||
if err != nil {
|
||||
log.Printf("Failed to attach to running gateway (PID: %d): %v", pid, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
if isGatewayProcessAliveLocked() {
|
||||
return
|
||||
}
|
||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||
gateway.cmd = nil
|
||||
}
|
||||
@@ -82,7 +127,7 @@ func (h *Handler) TryAutoStartGateway() {
|
||||
return
|
||||
}
|
||||
|
||||
pid, err := h.startGatewayLocked("starting")
|
||||
pid, err := h.startGatewayLocked("starting", 0)
|
||||
if err != nil {
|
||||
log.Printf("Failed to auto-start gateway: %v", err)
|
||||
return
|
||||
@@ -125,10 +170,6 @@ func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig
|
||||
return modelCfg
|
||||
}
|
||||
|
||||
func isGatewayProcessAliveLocked() bool {
|
||||
return isCmdProcessAliveLocked(gateway.cmd)
|
||||
}
|
||||
|
||||
func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return false
|
||||
@@ -157,6 +198,28 @@ func setGatewayRuntimeStatusLocked(status string) {
|
||||
gateway.startupDeadline = time.Time{}
|
||||
}
|
||||
|
||||
// attachToGatewayProcess attaches to an existing gateway process by PID
|
||||
// and updates the gateway state accordingly.
|
||||
// Assumes gateway.mu is held by the caller.
|
||||
func attachToGatewayProcessLocked(pid int, cfg *config.Config) error {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find process for PID %d: %w", pid, err)
|
||||
}
|
||||
|
||||
gateway.cmd = &exec.Cmd{Process: process}
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
|
||||
// Update bootDefaultModel from config
|
||||
if cfg != nil {
|
||||
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
gateway.bootDefaultModel = defaultModelName
|
||||
}
|
||||
|
||||
log.Printf("Attached to gateway process (PID: %d)", pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func gatewayStatusOnHealthFailureLocked() string {
|
||||
if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" {
|
||||
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
|
||||
@@ -238,24 +301,41 @@ func stopGatewayProcessForRestart(cmd *exec.Cmd) error {
|
||||
return fmt.Errorf("existing gateway did not exit before restart")
|
||||
}
|
||||
|
||||
func gatewayRestartRequired(status, bootDefaultModel, configDefaultModel string) bool {
|
||||
return status == "running" &&
|
||||
bootDefaultModel != "" &&
|
||||
configDefaultModel != "" &&
|
||||
bootDefaultModel != configDefaultModel
|
||||
}
|
||||
|
||||
func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||
func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int, error) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
|
||||
var cmd *exec.Cmd
|
||||
var pid int
|
||||
|
||||
if existingPid > 0 {
|
||||
// Attach to existing process
|
||||
pid = existingPid
|
||||
gateway.cmd = nil // Clear first to ensure clean state
|
||||
if err = attachToGatewayProcessLocked(pid, cfg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Broadcast the attached state
|
||||
gateway.events.Broadcast(GatewayEvent{
|
||||
Status: initialStatus,
|
||||
PID: pid,
|
||||
BootDefaultModel: defaultModelName,
|
||||
ConfigDefaultModel: defaultModelName,
|
||||
RestartRequired: false,
|
||||
})
|
||||
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// Start new process
|
||||
// Locate the picoclaw executable
|
||||
execPath := utils.FindPicoclawBinary()
|
||||
|
||||
cmd := exec.Command(execPath, "gateway")
|
||||
cmd = exec.Command(execPath, "gateway")
|
||||
cmd.Env = os.Environ()
|
||||
// Forward the launcher's config path via the environment variable that
|
||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||
@@ -293,7 +373,7 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||
gateway.cmd = cmd
|
||||
gateway.bootDefaultModel = defaultModelName
|
||||
setGatewayRuntimeStatusLocked(initialStatus)
|
||||
pid := cmd.Process.Pid
|
||||
pid = cmd.Process.Pid
|
||||
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
|
||||
|
||||
// Broadcast the launch state immediately so clients can reflect it without polling.
|
||||
@@ -351,30 +431,22 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||
healthPort := cfg.Gateway.Port
|
||||
if healthPort == 0 {
|
||||
healthPort = 18790
|
||||
}
|
||||
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
|
||||
resp, err := gatewayHealthGet(healthURL, 1*time.Second)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == cmd {
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
gateway.events.Broadcast(GatewayEvent{
|
||||
Status: "running",
|
||||
PID: pid,
|
||||
BootDefaultModel: defaultModelName,
|
||||
ConfigDefaultModel: defaultModelName,
|
||||
RestartRequired: false,
|
||||
})
|
||||
return
|
||||
healthResp, statusCode, err := getGatewayHealth(cfg.Gateway.Port, 1*time.Second)
|
||||
if err == nil && statusCode == http.StatusOK && healthResp.Pid == pid {
|
||||
// Verify the health endpoint returns the expected pid
|
||||
gateway.mu.Lock()
|
||||
if gateway.cmd == cmd {
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
gateway.events.Broadcast(GatewayEvent{
|
||||
Status: "running",
|
||||
PID: pid,
|
||||
BootDefaultModel: defaultModelName,
|
||||
ConfigDefaultModel: defaultModelName,
|
||||
RestartRequired: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -386,19 +458,54 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||
//
|
||||
// POST /api/gateway/start
|
||||
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
||||
// Prevent duplicate starts by checking health endpoint
|
||||
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||
if cfgErr == nil && cfg != nil {
|
||||
healthResp, statusCode, err := getGatewayHealth(cfg.Gateway.Port, 2*time.Second)
|
||||
if err == nil && statusCode == http.StatusOK {
|
||||
// Gateway is already running, attach to the existing process
|
||||
pid := healthResp.Pid
|
||||
gateway.mu.Lock()
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
gateway.mu.Unlock()
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
if !ready {
|
||||
gateway.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "precondition_failed",
|
||||
"message": reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
_, err = h.startGatewayLocked("starting", pid)
|
||||
gateway.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("Failed to attach to running gateway (PID: %d): %v", pid, err)
|
||||
http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"pid": pid,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
@@ -423,7 +530,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
pid, err := h.startGatewayLocked("starting")
|
||||
pid, err := h.startGatewayLocked("starting", 0)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -475,27 +582,16 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// RestartGateway restarts the gateway process. This is a non-blocking operation
|
||||
// that stops the current gateway (if running) and starts a new one.
|
||||
// Returns the PID of the new gateway process or an error.
|
||||
func (h *Handler) RestartGateway() (int, error) {
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
return 0, fmt.Errorf("failed to validate gateway start conditions: %w", err)
|
||||
}
|
||||
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
|
||||
return 0, &preconditionFailedError{reason: reason}
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
@@ -519,8 +615,7 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
return 0, fmt.Errorf("failed to stop gateway: %w", err)
|
||||
}
|
||||
|
||||
gateway.mu.Lock()
|
||||
@@ -528,7 +623,7 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
}
|
||||
pid, err := h.startGatewayLocked("restarting")
|
||||
pid, err := h.startGatewayLocked("restarting", 0)
|
||||
if err != nil {
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
@@ -536,6 +631,43 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to start gateway: %w", err)
|
||||
}
|
||||
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// preconditionFailedError is returned when gateway restart preconditions are not met
|
||||
type preconditionFailedError struct {
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e *preconditionFailedError) Error() string {
|
||||
return e.reason
|
||||
}
|
||||
|
||||
// IsBadRequest returns true if the error should result in a 400 Bad Request status
|
||||
func (e *preconditionFailedError) IsBadRequest() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
pid, err := h.RestartGateway()
|
||||
if err != nil {
|
||||
// Check if it's a precondition failed error
|
||||
var precondErr *preconditionFailedError
|
||||
if errors.As(err, &precondErr) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "precondition_failed",
|
||||
"message": precondErr.reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -573,83 +705,74 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) gatewayStatusData() map[string]any {
|
||||
data := map[string]any{}
|
||||
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||
configDefaultModel := ""
|
||||
if cfgErr == nil && cfg != nil {
|
||||
configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
configDefaultModel := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
if configDefaultModel != "" {
|
||||
data["config_default_model"] = configDefaultModel
|
||||
}
|
||||
}
|
||||
|
||||
// Check process state
|
||||
gateway.mu.Lock()
|
||||
processAlive := isGatewayProcessAliveLocked()
|
||||
bootDefaultModel := ""
|
||||
if processAlive {
|
||||
data["pid"] = gateway.cmd.Process.Pid
|
||||
if gateway.bootDefaultModel != "" {
|
||||
data["boot_default_model"] = gateway.bootDefaultModel
|
||||
bootDefaultModel = gateway.bootDefaultModel
|
||||
}
|
||||
// Probe health endpoint to get pid and status
|
||||
port := 0
|
||||
if cfgErr == nil && cfg != nil {
|
||||
port = cfg.Gateway.Port
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if !processAlive {
|
||||
healthResp, statusCode, err := getGatewayHealth(port, 2*time.Second)
|
||||
if err != nil {
|
||||
gateway.mu.Lock()
|
||||
data["gateway_status"] = currentGatewayStatusLocked(false)
|
||||
data["gateway_status"] = currentGatewayStatusLocked(true)
|
||||
gateway.mu.Unlock()
|
||||
log.Printf("Gateway health check failed: %v", err)
|
||||
} else {
|
||||
// Process is alive — probe its health endpoint
|
||||
host := "127.0.0.1"
|
||||
port := 18790
|
||||
if cfgErr == nil && cfg != nil {
|
||||
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||
if cfg.Gateway.Port != 0 {
|
||||
port = cfg.Gateway.Port
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
resp, err := gatewayHealthGet(url, 2*time.Second)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Gateway health status: %d", statusCode)
|
||||
if statusCode != http.StatusOK {
|
||||
gateway.mu.Lock()
|
||||
data["gateway_status"] = currentGatewayStatusLocked(true)
|
||||
setGatewayRuntimeStatusLocked("error")
|
||||
gateway.mu.Unlock()
|
||||
data["gateway_status"] = "error"
|
||||
data["status_code"] = statusCode
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
gateway.mu.Lock()
|
||||
setGatewayRuntimeStatusLocked("error")
|
||||
gateway.mu.Unlock()
|
||||
data["gateway_status"] = "error"
|
||||
data["status_code"] = resp.StatusCode
|
||||
gateway.mu.Lock()
|
||||
// Check if this pid matches our tracked process
|
||||
if gateway.cmd != nil && gateway.cmd.Process != nil && gateway.cmd.Process.Pid == healthResp.Pid {
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
bootDefaultModel := gateway.bootDefaultModel
|
||||
if bootDefaultModel != "" {
|
||||
data["boot_default_model"] = bootDefaultModel
|
||||
}
|
||||
data["gateway_status"] = "running"
|
||||
data["pid"] = healthResp.Pid
|
||||
} else {
|
||||
var healthData map[string]any
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||
gateway.mu.Lock()
|
||||
// Health endpoint responded with a different pid
|
||||
// This could be a manual restart, try to attach to the new process
|
||||
oldPid := "none"
|
||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||
oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid)
|
||||
}
|
||||
log.Printf("Detected new gateway PID (old: %s, new: %d), attempting to attach", oldPid, healthResp.Pid)
|
||||
|
||||
if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil {
|
||||
// Failed to find the process, treat as error
|
||||
setGatewayRuntimeStatusLocked("error")
|
||||
gateway.mu.Unlock()
|
||||
data["gateway_status"] = "error"
|
||||
data["pid"] = healthResp.Pid
|
||||
log.Printf("Failed to attach to new gateway process (PID: %d): %v", healthResp.Pid, err)
|
||||
} else {
|
||||
gateway.mu.Lock()
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
for k, v := range healthData {
|
||||
data[k] = v
|
||||
// Successfully attached, update response data
|
||||
bootDefaultModel := gateway.bootDefaultModel
|
||||
if bootDefaultModel != "" {
|
||||
data["boot_default_model"] = bootDefaultModel
|
||||
}
|
||||
data["gateway_status"] = "running"
|
||||
data["pid"] = healthResp.Pid
|
||||
}
|
||||
}
|
||||
gateway.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
status, _ := data["gateway_status"].(string)
|
||||
data["gateway_restart_required"] = gatewayRestartRequired(
|
||||
status,
|
||||
bootDefaultModel,
|
||||
configDefaultModel,
|
||||
)
|
||||
data["gateway_restart_required"] = false
|
||||
|
||||
ready, reason, readyErr := h.gatewayStartReady()
|
||||
if readyErr != nil {
|
||||
|
||||
@@ -494,60 +494,6 @@ func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusIncludesRestartRequiredWhenModelsDiffer(t *testing.T) {
|
||||
resetGatewayTestState(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||
cfg.ModelList[0].APIKey = "test-key"
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
cmd := startLongRunningProcess(t)
|
||||
t.Cleanup(func() {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = cmd
|
||||
gateway.bootDefaultModel = "previous-model"
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
_, _ = rec.WriteString(`{"ok":true}`)
|
||||
return rec.Result(), nil
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got := body["gateway_restart_required"]; got != true {
|
||||
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
|
||||
@@ -70,3 +70,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Launcher service parameters (port/public)
|
||||
h.registerLauncherConfigRoutes(mux)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the handler, closing all SSE connections.
|
||||
func (h *Handler) Shutdown() {
|
||||
gateway.events.Shutdown()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Language represents the supported languages
|
||||
type Language string
|
||||
|
||||
const (
|
||||
LanguageEnglish Language = "en"
|
||||
LanguageChinese Language = "zh"
|
||||
)
|
||||
|
||||
// current language (default: English)
|
||||
var currentLang Language = LanguageEnglish
|
||||
|
||||
// TranslationKey represents a translation key used for i18n
|
||||
type TranslationKey string
|
||||
|
||||
const (
|
||||
AppTooltip TranslationKey = "AppTooltip"
|
||||
MenuOpen TranslationKey = "MenuOpen"
|
||||
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
|
||||
MenuAbout TranslationKey = "MenuAbout"
|
||||
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
|
||||
MenuVersion TranslationKey = "MenuVersion"
|
||||
MenuVersionTooltip TranslationKey = "MenuVersionTooltip"
|
||||
MenuGitHub TranslationKey = "MenuGitHub"
|
||||
MenuDocs TranslationKey = "MenuDocs"
|
||||
MenuRestart TranslationKey = "MenuRestart"
|
||||
MenuRestartTooltip TranslationKey = "MenuRestartTooltip"
|
||||
MenuQuit TranslationKey = "MenuQuit"
|
||||
MenuQuitTooltip TranslationKey = "MenuQuitTooltip"
|
||||
Exiting TranslationKey = "Exiting"
|
||||
DocUrl TranslationKey = "DocUrl"
|
||||
)
|
||||
|
||||
// Translation tables
|
||||
// Chinese translations intentionally contain Han script
|
||||
//
|
||||
//nolint:gosmopolitan
|
||||
var translations = map[Language]map[TranslationKey]string{
|
||||
LanguageEnglish: {
|
||||
AppTooltip: "%s - Web Console",
|
||||
MenuOpen: "Open Console",
|
||||
MenuOpenTooltip: "Open PicoClaw console in browser",
|
||||
MenuAbout: "About",
|
||||
MenuAboutTooltip: "About PicoClaw",
|
||||
MenuVersion: "Version: %s",
|
||||
MenuVersionTooltip: "Current version number",
|
||||
MenuGitHub: "GitHub",
|
||||
MenuDocs: "Documentation",
|
||||
MenuRestart: "Restart Service",
|
||||
MenuRestartTooltip: "Restart Gateway service",
|
||||
MenuQuit: "Quit",
|
||||
MenuQuitTooltip: "Exit PicoClaw",
|
||||
Exiting: "Exiting PicoClaw...",
|
||||
DocUrl: "https://docs.picoclaw.io/docs/",
|
||||
},
|
||||
LanguageChinese: {
|
||||
AppTooltip: "%s - Web Console",
|
||||
MenuOpen: "打开控制台",
|
||||
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
|
||||
MenuAbout: "关于",
|
||||
MenuAboutTooltip: "关于 PicoClaw",
|
||||
MenuVersion: "版本: %s",
|
||||
MenuVersionTooltip: "当前版本号",
|
||||
MenuGitHub: "GitHub",
|
||||
MenuDocs: "文档",
|
||||
MenuRestart: "重启服务",
|
||||
MenuRestartTooltip: "重启核心服务",
|
||||
MenuQuit: "退出",
|
||||
MenuQuitTooltip: "退出 PicoClaw",
|
||||
Exiting: "正在退出 PicoClaw...",
|
||||
DocUrl: "https://docs.picoclaw.io/zh-Hans/docs/",
|
||||
},
|
||||
}
|
||||
|
||||
// SetLanguage sets the current language
|
||||
func SetLanguage(lang string) {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
|
||||
// Extract language code before first underscore or dot
|
||||
// e.g., "en_US.UTF-8" -> "en", "zh_CN" -> "zh"
|
||||
if idx := strings.IndexAny(lang, "_."); idx > 0 {
|
||||
lang = lang[:idx]
|
||||
}
|
||||
|
||||
if lang == "zh" || lang == "zh-cn" || lang == "chinese" {
|
||||
currentLang = LanguageChinese
|
||||
} else {
|
||||
currentLang = LanguageEnglish
|
||||
}
|
||||
}
|
||||
|
||||
// GetLanguage returns the current language
|
||||
func GetLanguage() Language {
|
||||
return currentLang
|
||||
}
|
||||
|
||||
// T translates a key to the current language
|
||||
func T(key TranslationKey, args ...any) string {
|
||||
if trans, ok := translations[currentLang][key]; ok {
|
||||
if len(args) > 0 {
|
||||
return fmt.Sprintf(trans, args...)
|
||||
}
|
||||
return trans
|
||||
}
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// Initialize i18n from environment variable
|
||||
func init() {
|
||||
if lang := os.Getenv("LANG"); lang != "" {
|
||||
SetLanguage(lang)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
+40
-16
@@ -22,16 +22,34 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/web/backend/api"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
appName = "PicoClaw"
|
||||
)
|
||||
|
||||
var (
|
||||
appVersion = config.Version
|
||||
|
||||
server *http.Server
|
||||
serverAddr string
|
||||
apiHandler *api.Handler
|
||||
|
||||
noBrowser *bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "18800", "Port to listen on")
|
||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||
noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup")
|
||||
noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup")
|
||||
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||
@@ -51,6 +69,11 @@ func main() {
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
// Set language from command line or auto-detect
|
||||
if *lang != "" {
|
||||
SetLanguage(*lang)
|
||||
}
|
||||
|
||||
// Resolve config path
|
||||
configPath := utils.GetDefaultConfigPath()
|
||||
if flag.NArg() > 0 {
|
||||
@@ -113,7 +136,7 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API Routes (e.g. /api/status)
|
||||
apiHandler := api.NewHandler(absPath)
|
||||
apiHandler = api.NewHandler(absPath)
|
||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||
apiHandler.RegisterRoutes(mux)
|
||||
|
||||
@@ -145,16 +168,10 @@ func main() {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Auto-open browser
|
||||
if !*noBrowser {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
url := "http://localhost:" + effectivePort
|
||||
if err := utils.OpenBrowser(url); err != nil {
|
||||
log.Printf("Warning: Failed to auto-open browser: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// Set server address for systray
|
||||
serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
|
||||
|
||||
// Auto-open browser will be handled by systray onReady
|
||||
|
||||
// Auto-start gateway after backend starts listening.
|
||||
go func() {
|
||||
@@ -162,8 +179,15 @@ func main() {
|
||||
apiHandler.TryAutoStartGateway()
|
||||
}()
|
||||
|
||||
// Start the Server
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
// Start the Server in a goroutine
|
||||
server = &http.Server{Addr: addr, Handler: handler}
|
||||
go func() {
|
||||
log.Printf("Server listening on %s", addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start system tray
|
||||
systray.Run(onReady, onExit)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
browserDelay = 500 * time.Millisecond
|
||||
shutdownTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// onReady is called when the system tray is ready
|
||||
func onReady() {
|
||||
// Set icon and tooltip
|
||||
systray.SetIcon(getIcon())
|
||||
systray.SetTooltip(fmt.Sprintf(T(AppTooltip), appName))
|
||||
|
||||
// Create menu items
|
||||
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
|
||||
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
|
||||
|
||||
// Add version info under About menu
|
||||
mVersion := mAbout.AddSubMenuItem(fmt.Sprintf(T(MenuVersion), appVersion), T(MenuVersionTooltip))
|
||||
mVersion.Disable()
|
||||
mRepo := mAbout.AddSubMenuItem(T(MenuGitHub), "")
|
||||
mDocs := mAbout.AddSubMenuItem(T(MenuDocs), "")
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
// Add restart option
|
||||
mRestart := systray.AddMenuItem(T(MenuRestart), T(MenuRestartTooltip))
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
// Quit option
|
||||
mQuit := systray.AddMenuItem(T(MenuQuit), T(MenuQuitTooltip))
|
||||
|
||||
// Handle menu clicks
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-mOpen.ClickedCh:
|
||||
if err := openBrowser(); err != nil {
|
||||
logger.Errorf("Failed to open browser: %v", err)
|
||||
}
|
||||
|
||||
case <-mVersion.ClickedCh:
|
||||
// Version info - do nothing, just shows current version
|
||||
|
||||
case <-mRepo.ClickedCh:
|
||||
if err := utils.OpenBrowser("https://github.com/sipeed/picoclaw"); err != nil {
|
||||
logger.Errorf("Failed to open GitHub: %v", err)
|
||||
}
|
||||
|
||||
case <-mDocs.ClickedCh:
|
||||
if err := utils.OpenBrowser(T(DocUrl)); err != nil {
|
||||
logger.Errorf("Failed to open docs: %v", err)
|
||||
}
|
||||
|
||||
case <-mRestart.ClickedCh:
|
||||
fmt.Println("Restart request received...")
|
||||
if apiHandler != nil {
|
||||
if pid, err := apiHandler.RestartGateway(); err != nil {
|
||||
logger.Errorf("Failed to restart gateway: %v", err)
|
||||
} else {
|
||||
logger.Infof("Gateway restarted (PID: %d)", pid)
|
||||
}
|
||||
}
|
||||
|
||||
case <-mQuit.ClickedCh:
|
||||
systray.Quit()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if !*noBrowser {
|
||||
// Auto-open browser after systray is ready (if not disabled)
|
||||
// Check no-browser flag via environment or pass as parameter if needed
|
||||
if err := openBrowser(); err != nil {
|
||||
logger.Errorf("Warning: Failed to auto-open browser: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onExit is called when the system tray is exiting
|
||||
func onExit() {
|
||||
fmt.Println(T(Exiting))
|
||||
|
||||
// First, shutdown API handler to close all SSE connections
|
||||
if apiHandler != nil {
|
||||
apiHandler.Shutdown()
|
||||
}
|
||||
|
||||
if server != nil {
|
||||
// Disable keep-alive to allow graceful shutdown
|
||||
server.SetKeepAlivesEnabled(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
// Context deadline exceeded is expected if there are active connections
|
||||
// This is not necessarily an error, so log it at info level
|
||||
if err == context.DeadlineExceeded {
|
||||
logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
|
||||
} else {
|
||||
logger.Errorf("Server shutdown error: %v", err)
|
||||
}
|
||||
} else {
|
||||
logger.Infof("Server shutdown completed successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// openBrowser opens the PicoClaw web console in the default browser
|
||||
func openBrowser() error {
|
||||
if serverAddr == "" {
|
||||
return fmt.Errorf("server address not set")
|
||||
}
|
||||
return utils.OpenBrowser(serverAddr)
|
||||
}
|
||||
|
||||
// getIcon returns the system tray icon
|
||||
func getIcon() []byte {
|
||||
return iconData
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed icon.png
|
||||
var iconData []byte
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed icon.ico
|
||||
var iconData []byte
|
||||
Reference in New Issue
Block a user