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
This commit is contained in:
wenjie 2026-03-09 18:27:40 +08:00
parent 7593526b1f
commit b06e9e3707
23 changed files with 2436 additions and 268 deletions

View file

@ -0,0 +1,85 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
type launcherConfigPayload struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
}
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/system/launcher-config", h.handleGetLauncherConfig)
mux.HandleFunc("PUT /api/system/launcher-config", h.handleUpdateLauncherConfig)
}
func (h *Handler) launcherConfigPath() string {
return launcherconfig.PathForAppConfig(h.configPath)
}
func (h *Handler) launcherFallbackConfig() launcherconfig.Config {
port := h.serverPort
if port <= 0 {
port = launcherconfig.DefaultPort
}
return launcherconfig.Config{
Port: port,
Public: h.serverPublic,
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
}
}
func (h *Handler) loadLauncherConfig() (launcherconfig.Config, error) {
return launcherconfig.Load(h.launcherConfigPath(), h.launcherFallbackConfig())
}
func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadLauncherConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
})
}
func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) {
var payload launcherConfigPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
cfg := launcherconfig.Config{
Port: payload.Port,
Public: payload.Public,
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
}
if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := launcherconfig.Save(h.launcherConfigPath(), cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save launcher config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
})
}

View file

@ -0,0 +1,115 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
h.SetServerOptions(19999, true, []string{"192.168.1.0/24"})
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/system/launcher-config", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var got launcherConfigPayload
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got.Port != 19999 || !got.Public {
t.Fatalf("response = %+v, want port=19999 public=true", got)
}
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
}
}
func TestPutLauncherConfigPersists(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
path := launcherconfig.PathForAppConfig(configPath)
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
if err != nil {
t.Fatalf("launcherconfig.Load() error = %v", err)
}
if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
}
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
}
}
func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(`{"port":70000,"public":false}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(`{"port":18080,"public":false,"allowed_cidrs":["bad-cidr"]}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}

View file

@ -3,11 +3,16 @@ package api
import (
"net/http"
"sync"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
// Handler serves HTTP API requests.
type Handler struct {
configPath string
serverPort int
serverPublic bool
serverCIDRs []string
oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow
oauthState map[string]string
@ -17,11 +22,19 @@ type Handler struct {
func NewHandler(configPath string) *Handler {
return &Handler{
configPath: configPath,
serverPort: launcherconfig.DefaultPort,
oauthFlows: make(map[string]*oauthFlow),
oauthState: make(map[string]string),
}
}
// SetServerOptions stores current backend listen options for fallback behavior.
func (h *Handler) SetServerOptions(port int, public bool, allowedCIDRs []string) {
h.serverPort = port
h.serverPublic = public
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
}
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Config CRUD
@ -44,4 +57,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Channel catalog (for frontend navigation/config pages)
h.registerChannelRoutes(mux)
// OS startup / launch-at-login
h.registerStartupRoutes(mux)
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
}

305
web/backend/api/startup.go Normal file
View file

@ -0,0 +1,305 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
const (
autoStartEntryName = "PicoClawLauncher"
launchAgentLabel = "io.picoclaw.launcher"
)
type autoStartRequest struct {
Enabled bool `json:"enabled"`
}
type autoStartResponse struct {
Enabled bool `json:"enabled"`
Supported bool `json:"supported"`
Platform string `json:"platform"`
Message string `json:"message,omitempty"`
}
var errAutoStartUnsupported = errors.New("autostart is not supported on this platform")
func (h *Handler) registerStartupRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/system/autostart", h.handleGetAutoStart)
mux.HandleFunc("PUT /api/system/autostart", h.handleSetAutoStart)
}
func (h *Handler) handleGetAutoStart(w http.ResponseWriter, r *http.Request) {
enabled, supported, message, err := h.getAutoStartStatus()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read startup setting: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(autoStartResponse{
Enabled: enabled,
Supported: supported,
Platform: runtime.GOOS,
Message: message,
})
}
func (h *Handler) handleSetAutoStart(w http.ResponseWriter, r *http.Request) {
var req autoStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err := h.setAutoStart(req.Enabled); err != nil {
if errors.Is(err, errAutoStartUnsupported) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, fmt.Sprintf("Failed to update startup setting: %v", err), http.StatusInternalServerError)
return
}
enabled, supported, message, err := h.getAutoStartStatus()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to verify startup setting: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(autoStartResponse{
Enabled: enabled,
Supported: supported,
Platform: runtime.GOOS,
Message: message,
})
}
func (h *Handler) resolveLaunchCommand() (string, []string, error) {
exePath, err := os.Executable()
if err != nil {
return "", nil, err
}
args := []string{"-no-browser"}
if h.configPath != "" {
args = append(args, h.configPath)
}
return exePath, args, nil
}
func (h *Handler) getAutoStartStatus() (enabled bool, supported bool, message string, err error) {
switch runtime.GOOS {
case "darwin":
exists, err := fileExists(macLaunchAgentPath())
return exists, true, "Changes apply on next login.", err
case "linux":
exists, err := fileExists(linuxAutoStartPath())
return exists, true, "Changes apply on next login.", err
case "windows":
exists, err := windowsRunKeyExists()
return exists, true, "Changes apply on next login.", err
default:
return false, false, "Current platform does not support launch at login.", nil
}
}
func (h *Handler) setAutoStart(enabled bool) error {
exePath, args, err := h.resolveLaunchCommand()
if err != nil {
return err
}
switch runtime.GOOS {
case "darwin":
return setDarwinAutoStart(enabled, exePath, args)
case "linux":
return setLinuxAutoStart(enabled, exePath, args)
case "windows":
return setWindowsAutoStart(enabled, exePath, args)
default:
return errAutoStartUnsupported
}
}
func fileExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func macLaunchAgentPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist")
}
func setDarwinAutoStart(enabled bool, exePath string, args []string) error {
plistPath := macLaunchAgentPath()
if enabled {
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
return err
}
content := buildDarwinPlist(exePath, args)
return os.WriteFile(plistPath, []byte(content), 0o644)
}
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func xmlEscape(s string) string {
var b bytes.Buffer
for _, r := range s {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
case '>':
b.WriteString("&gt;")
case '"':
b.WriteString("&quot;")
case '\'':
b.WriteString("&apos;")
default:
b.WriteRune(r)
}
}
return b.String()
}
func buildDarwinPlist(exePath string, args []string) string {
programArgs := make([]string, 0, len(args)+1)
programArgs = append(programArgs, exePath)
programArgs = append(programArgs, args...)
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n",
)
b.WriteString(`<plist version="1.0">` + "\n")
b.WriteString(`<dict>` + "\n")
b.WriteString(` <key>Label</key>` + "\n")
b.WriteString(` <string>` + launchAgentLabel + `</string>` + "\n")
b.WriteString(` <key>ProgramArguments</key>` + "\n")
b.WriteString(` <array>` + "\n")
for _, arg := range programArgs {
b.WriteString(` <string>` + xmlEscape(arg) + `</string>` + "\n")
}
b.WriteString(` </array>` + "\n")
b.WriteString(` <key>RunAtLoad</key>` + "\n")
b.WriteString(` <true/>` + "\n")
b.WriteString(` <key>ProcessType</key>` + "\n")
b.WriteString(` <string>Background</string>` + "\n")
b.WriteString(`</dict>` + "\n")
b.WriteString(`</plist>` + "\n")
return b.String()
}
func linuxAutoStartPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "autostart", "picoclaw-web.desktop")
}
func shellQuote(s string) string {
if s == "" {
return "''"
}
if !strings.ContainsAny(s, " \t\n'\"\\$`") {
return s
}
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
func buildLinuxExecLine(exePath string, args []string) string {
parts := make([]string, 0, len(args)+1)
parts = append(parts, shellQuote(exePath))
for _, arg := range args {
parts = append(parts, shellQuote(arg))
}
return strings.Join(parts, " ")
}
func setLinuxAutoStart(enabled bool, exePath string, args []string) error {
desktopPath := linuxAutoStartPath()
if enabled {
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err
}
content := strings.Join([]string{
"[Desktop Entry]",
"Type=Application",
"Version=1.0",
"Name=PicoClaw Web",
"Comment=Start PicoClaw Web on login",
"Exec=" + buildLinuxExecLine(exePath, args),
"Terminal=false",
"X-GNOME-Autostart-enabled=true",
"NoDisplay=true",
"",
}, "\n")
return os.WriteFile(desktopPath, []byte(content), 0o644)
}
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func windowsCommandLine(exePath string, args []string) string {
parts := make([]string, 0, len(args)+1)
parts = append(parts, fmt.Sprintf("%q", exePath))
for _, arg := range args {
parts = append(parts, fmt.Sprintf("%q", arg))
}
return strings.Join(parts, " ")
}
func windowsRunKeyExists() (bool, error) {
cmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autoStartEntryName)
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return false, nil
}
return false, err
}
return true, nil
}
func setWindowsAutoStart(enabled bool, exePath string, args []string) error {
key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
if enabled {
commandLine := windowsCommandLine(exePath, args)
cmd := exec.Command("reg", "add", key, "/v", autoStartEntryName, "/t", "REG_SZ", "/d", commandLine, "/f")
return cmd.Run()
}
cmd := exec.Command("reg", "delete", key, "/v", autoStartEntryName, "/f")
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil
}
return err
}
return nil
}

View file

@ -0,0 +1,56 @@
package api
import (
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
// Persist non-default launcher options to ensure resolveLaunchCommand does not
// pin them into autostart args.
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 19999,
Public: true,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}
exePath, args, err := h.resolveLaunchCommand()
if err != nil {
t.Fatalf("resolveLaunchCommand() error = %v", err)
}
if exePath == "" {
t.Fatal("resolveLaunchCommand() returned empty executable path")
}
if len(args) != 2 {
t.Fatalf("args len = %d, want 2 (got %v)", len(args), args)
}
if args[0] != "-no-browser" {
t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
}
if args[1] != configPath {
t.Fatalf("args[1] = %q, want %q", args[1], configPath)
}
for _, arg := range args {
if arg == "-port" || arg == "-public" {
t.Fatalf("autostart args should not pin network flags, got %v", args)
}
}
}
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
if !strings.Contains(plist, "<key>RunAtLoad</key>") {
t.Fatalf("plist missing RunAtLoad key:\n%s", plist)
}
if !strings.Contains(plist, "<true/>") {
t.Fatalf("plist missing RunAtLoad true value:\n%s", plist)
}
}

View file

@ -0,0 +1,113 @@
package launcherconfig
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
)
const (
// FileName is the launcher-specific settings file name.
FileName = "launcher-config.json"
// DefaultPort is the default port for the web launcher.
DefaultPort = 18800
)
// Config stores launch parameters for the web backend service.
type Config struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
}
// Default returns default launcher settings.
func Default() Config {
return Config{Port: DefaultPort, Public: false}
}
// Validate checks if launcher settings are valid.
func Validate(cfg Config) error {
if cfg.Port < 1 || cfg.Port > 65535 {
return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port)
}
for _, cidr := range cfg.AllowedCIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("invalid CIDR %q", cidr)
}
}
return nil
}
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
func NormalizeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 {
return nil
}
out := make([]string, 0, len(cidrs))
seen := make(map[string]struct{}, len(cidrs))
for _, raw := range cidrs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
if len(out) == 0 {
return nil
}
return out
}
// PathForAppConfig returns launcher-config path near the app config file.
func PathForAppConfig(appConfigPath string) string {
dir := filepath.Dir(appConfigPath)
if dir == "" || dir == "." {
dir = "."
}
return filepath.Join(dir, FileName)
}
// Load reads launcher settings; fallback is returned when file does not exist.
func Load(path string, fallback Config) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return fallback, nil
}
return Config{}, err
}
cfg := fallback
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
if err := Validate(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
// Save writes launcher settings to disk.
func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
if err := Validate(cfg); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o600)
}

View file

@ -0,0 +1,89 @@
package launcherconfig
import (
"os"
"path/filepath"
"testing"
)
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
path := filepath.Join(t.TempDir(), "launcher-config.json")
fallback := Config{Port: 19999, Public: true}
got, err := Load(path, fallback)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got.Port != fallback.Port || got.Public != fallback.Public {
t.Fatalf("Load() = %+v, want %+v", got, fallback)
}
}
func TestSaveAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launcher-config.json")
want := Config{
Port: 18080,
Public: true,
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
}
if err := Save(path, want); err != nil {
t.Fatalf("Save() error = %v", err)
}
got, err := Load(path, Default())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got.Port != want.Port || got.Public != want.Public {
t.Fatalf("Load() = %+v, want %+v", got, want)
}
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
}
for i := range want.AllowedCIDRs {
if got.AllowedCIDRs[i] != want.AllowedCIDRs[i] {
t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i])
}
}
stat, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if perm := stat.Mode().Perm(); perm != 0o600 {
t.Fatalf("file perm = %o, want 600", perm)
}
}
func TestValidateRejectsInvalidPort(t *testing.T) {
if err := Validate(Config{Port: 0, Public: false}); err == nil {
t.Fatal("Validate() expected error for port 0")
}
if err := Validate(Config{Port: 65536, Public: false}); err == nil {
t.Fatal("Validate() expected error for port 65536")
}
}
func TestValidateRejectsInvalidCIDR(t *testing.T) {
err := Validate(Config{
Port: 18800,
AllowedCIDRs: []string{"192.168.1.0/24", "not-a-cidr"},
})
if err == nil {
t.Fatal("Validate() expected error for invalid CIDR")
}
}
func TestNormalizeCIDRs(t *testing.T) {
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}

View file

@ -12,15 +12,18 @@
package main
import (
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
@ -58,12 +61,47 @@ func main() {
log.Fatalf("Failed to resolve config path: %v", err)
}
var explicitPort bool
var explicitPublic bool
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "port":
explicitPort = true
case "public":
explicitPublic = true
}
})
launcherPath := launcherconfig.PathForAppConfig(absPath)
launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default())
if err != nil {
log.Printf("Warning: Failed to load %s: %v", launcherPath, err)
launcherCfg = launcherconfig.Default()
}
effectivePort := *port
effectivePublic := *public
if !explicitPort {
effectivePort = strconv.Itoa(launcherCfg.Port)
}
if !explicitPublic {
effectivePublic = launcherCfg.Public
}
portNum, err := strconv.Atoi(effectivePort)
if err != nil || portNum < 1 || portNum > 65535 {
if err == nil {
err = errors.New("must be in range 1-65535")
}
log.Fatalf("Invalid port %q: %v", effectivePort, err)
}
// Determine listen address
var addr string
if *public {
addr = "0.0.0.0:" + *port
if effectivePublic {
addr = "0.0.0.0:" + effectivePort
} else {
addr = "127.0.0.1:" + *port
addr = "127.0.0.1:" + effectivePort
}
// Initialize Server components
@ -71,15 +109,21 @@ func main() {
// API Routes (e.g. /api/status)
apiHandler := api.NewHandler(absPath)
apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs)
apiHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
registerEmbedRoutes(mux)
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
if err != nil {
log.Fatalf("Invalid allowed CIDR configuration: %v", err)
}
// Apply middleware stack
handler := middleware.Recoverer(
middleware.Logger(
middleware.JSONContentType(mux),
middleware.JSONContentType(accessControlledMux),
),
)
@ -88,10 +132,10 @@ func main() {
fmt.Println()
fmt.Println(" Open the following URL in your browser:")
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", *port)
if *public {
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
if effectivePublic {
if ip := getLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, *port)
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
}
}
fmt.Println()
@ -100,7 +144,7 @@ func main() {
if !*noBrowser {
go func() {
time.Sleep(500 * time.Millisecond)
url := "http://localhost:" + *port
url := "http://localhost:" + effectivePort
if err := openBrowser(url); err != nil {
log.Printf("Warning: Failed to auto-open browser: %v", err)
}

View file

@ -0,0 +1,64 @@
package middleware
import (
"fmt"
"net"
"net/http"
"strings"
)
// IPAllowlist restricts access to requests from configured CIDR ranges.
// Loopback addresses are always allowed for local administration.
// Empty CIDR list means no restriction.
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
if len(allowedCIDRs) == 0 {
return next, nil
}
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
for _, cidr := range allowedCIDRs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
}
nets = append(nets, ipNet)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := clientIPFromRemoteAddr(r.RemoteAddr)
if ip == nil {
rejectByPolicy(w, r)
return
}
if ip.IsLoopback() {
next.ServeHTTP(w, r)
return
}
for _, ipNet := range nets {
if ipNet.Contains(ip) {
next.ServeHTTP(w, r)
return
}
}
rejectByPolicy(w, r)
}), nil
}
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
return net.ParseIP(host)
}
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"access denied by network policy"}`))
return
}
http.Error(w, "Forbidden", http.StatusForbidden)
}

View file

@ -0,0 +1,86 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {
h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.5:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) {
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
req.RemoteAddr = "10.0.0.8:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) {
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.168.1.88:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) {
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
if err != nil {
t.Fatalf("IPAllowlist() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:1234"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestIPAllowlist_InvalidCIDR(t *testing.T) {
_, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
if err == nil {
t.Fatal("IPAllowlist() expected error for invalid CIDR")
}
}

View file

@ -0,0 +1,62 @@
export interface AutoStartStatus {
enabled: boolean
supported: boolean
platform: string
message?: string
}
export interface LauncherConfig {
port: number
public: boolean
allowed_cidrs: string[]
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, options)
if (!res.ok) {
let message = `API error: ${res.status} ${res.statusText}`
try {
const body = (await res.json()) as {
error?: string
errors?: string[]
}
if (Array.isArray(body.errors) && body.errors.length > 0) {
message = body.errors.join("; ")
} else if (typeof body.error === "string" && body.error.trim() !== "") {
message = body.error
}
} catch {
// Keep fallback error message when response body is not JSON.
}
throw new Error(message)
}
return res.json() as Promise<T>
}
export async function getAutoStartStatus(): Promise<AutoStartStatus> {
return request<AutoStartStatus>("/api/system/autostart")
}
export async function setAutoStartEnabled(
enabled: boolean,
): Promise<AutoStartStatus> {
return request<AutoStartStatus>("/api/system/autostart", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
})
}
export async function getLauncherConfig(): Promise<LauncherConfig> {
return request<LauncherConfig>("/api/system/launcher-config")
}
export async function setLauncherConfig(
payload: LauncherConfig,
): Promise<LauncherConfig> {
return request<LauncherConfig>("/api/system/launcher-config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
}

View file

@ -154,7 +154,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarGroupContent className="pt-1">
<SidebarMenu>
{group.items.map((item) => {
const isActive = currentPath === item.url
const isActive =
currentPath === item.url ||
(item.url !== "/" &&
currentPath.startsWith(`${item.url}/`))
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton

View file

@ -0,0 +1,374 @@
import { IconCode, IconDeviceFloppy } from "@tabler/icons-react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { patchAppConfig } from "@/api/channels"
import {
getAutoStartStatus,
getLauncherConfig,
setAutoStartEnabled as updateAutoStartEnabled,
setLauncherConfig as updateLauncherConfig,
} from "@/api/system"
import {
AdvancedSection,
AgentDefaultsSection,
DevicesSection,
LauncherSection,
RuntimeSection,
} from "@/components/config/config-sections"
import {
type CoreConfigForm,
EMPTY_FORM,
EMPTY_LAUNCHER_FORM,
type LauncherForm,
buildFormFromConfig,
parseCIDRText,
parseIntField,
} from "@/components/config/form-model"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
export function ConfigPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [form, setForm] = useState<CoreConfigForm>(EMPTY_FORM)
const [baseline, setBaseline] = useState<CoreConfigForm>(EMPTY_FORM)
const [launcherForm, setLauncherForm] =
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
const [launcherBaseline, setLauncherBaseline] =
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
const [autoStartEnabled, setAutoStartEnabled] = useState(false)
const [autoStartBaseline, setAutoStartBaseline] = useState(false)
const [saving, setSaving] = useState(false)
const { data, isLoading, error } = useQuery({
queryKey: ["config"],
queryFn: async () => {
const res = await fetch("/api/config")
if (!res.ok) {
throw new Error("Failed to load config")
}
return res.json()
},
})
const {
data: launcherConfig,
isLoading: isLauncherLoading,
error: launcherError,
} = useQuery({
queryKey: ["system", "launcher-config"],
queryFn: getLauncherConfig,
})
const {
data: autoStartStatus,
isLoading: isAutoStartLoading,
error: autoStartError,
} = useQuery({
queryKey: ["system", "autostart"],
queryFn: getAutoStartStatus,
})
useEffect(() => {
if (!data) return
const parsed = buildFormFromConfig(data)
setForm(parsed)
setBaseline(parsed)
}, [data])
useEffect(() => {
if (!launcherConfig) return
const parsed: LauncherForm = {
port: String(launcherConfig.port),
publicAccess: launcherConfig.public,
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
}
setLauncherForm(parsed)
setLauncherBaseline(parsed)
}, [launcherConfig])
useEffect(() => {
if (!autoStartStatus) return
setAutoStartEnabled(autoStartStatus.enabled)
setAutoStartBaseline(autoStartStatus.enabled)
}, [autoStartStatus])
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
const launcherDirty =
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)
const autoStartDirty = autoStartEnabled !== autoStartBaseline
const isDirty = configDirty || launcherDirty || autoStartDirty
const autoStartSupported = autoStartStatus?.supported !== false
const autoStartHint = autoStartError
? t(
"pages.config.autostart_load_error",
"Failed to load launch-at-login status.",
)
: !autoStartSupported
? t(
"pages.config.autostart_unsupported",
"Launch at login is not supported on this platform.",
)
: t(
"pages.config.autostart_hint",
"Start PicoClaw Web automatically when you log in.",
)
const launcherHint = launcherError
? t(
"pages.config.launcher_load_error",
"Failed to load service parameters.",
)
: t(
"pages.config.launcher_restart_hint",
"Service parameter changes apply after restarting PicoClaw Web.",
)
const updateField = <K extends keyof CoreConfigForm>(
key: K,
value: CoreConfigForm[K],
) => {
setForm((prev) => ({ ...prev, [key]: value }))
}
const updateLauncherField = <K extends keyof LauncherForm>(
key: K,
value: LauncherForm[K],
) => {
setLauncherForm((prev) => ({ ...prev, [key]: value }))
}
const handleReset = () => {
setForm(baseline)
setLauncherForm(launcherBaseline)
setAutoStartEnabled(autoStartBaseline)
toast.info(
t(
"pages.config.reset_success",
"Changes have been reset to the last saved state.",
),
)
}
const handleSave = async () => {
try {
setSaving(true)
if (configDirty) {
const workspace = form.workspace.trim()
const dmScope = form.dmScope.trim()
if (!workspace) {
throw new Error("Workspace path is required.")
}
if (!dmScope) {
throw new Error("Session scope is required.")
}
const maxTokens = parseIntField(form.maxTokens, "Max tokens", {
min: 1,
})
const maxToolIterations = parseIntField(
form.maxToolIterations,
"Max tool iterations",
{ min: 1 },
)
const summarizeMessageThreshold = parseIntField(
form.summarizeMessageThreshold,
"Summarize message threshold",
{ min: 1 },
)
const summarizeTokenPercent = parseIntField(
form.summarizeTokenPercent,
"Summarize token percent",
{ min: 1, max: 100 },
)
const heartbeatInterval = parseIntField(
form.heartbeatInterval,
"Heartbeat interval",
{ min: 1 },
)
await patchAppConfig({
agents: {
defaults: {
workspace,
restrict_to_workspace: form.restrictToWorkspace,
max_tokens: maxTokens,
max_tool_iterations: maxToolIterations,
summarize_message_threshold: summarizeMessageThreshold,
summarize_token_percent: summarizeTokenPercent,
},
},
session: {
dm_scope: dmScope,
},
heartbeat: {
enabled: form.heartbeatEnabled,
interval: heartbeatInterval,
},
devices: {
enabled: form.devicesEnabled,
monitor_usb: form.monitorUSB,
},
})
setBaseline(form)
queryClient.invalidateQueries({ queryKey: ["config"] })
}
if (launcherDirty) {
const port = parseIntField(launcherForm.port, "Service port", {
min: 1,
max: 65535,
})
const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)
const savedLauncherConfig = await updateLauncherConfig({
port,
public: launcherForm.publicAccess,
allowed_cidrs: allowedCIDRs,
})
const parsedLauncher: LauncherForm = {
port: String(savedLauncherConfig.port),
publicAccess: savedLauncherConfig.public,
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
"\n",
),
}
setLauncherForm(parsedLauncher)
setLauncherBaseline(parsedLauncher)
queryClient.setQueryData(
["system", "launcher-config"],
savedLauncherConfig,
)
}
if (autoStartDirty) {
if (!autoStartSupported) {
throw new Error(
t(
"pages.config.autostart_unsupported",
"Launch at login is not supported on this platform.",
),
)
}
const status = await updateAutoStartEnabled(autoStartEnabled)
setAutoStartEnabled(status.enabled)
setAutoStartBaseline(status.enabled)
queryClient.setQueryData(["system", "autostart"], status)
}
toast.success(
t("pages.config.save_success", "Configuration saved successfully."),
)
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t("pages.config.save_error", "Failed to save configuration."),
)
} finally {
setSaving(false)
}
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.config", "Config")}
children={
<Button variant="outline" asChild>
<Link to="/config/raw">
<IconCode className="size-4" />
{t("pages.config.open_raw", "Raw Config")}
</Link>
</Button>
}
/>
<div className="flex-1 overflow-auto p-3 lg:p-6">
<div className="mx-auto w-full max-w-[1000px] space-y-6">
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">
{t("labels.loading", "Loading...")}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{t(
"pages.config.load_error",
"Failed to load configuration. Please refresh and try again.",
)}
</div>
) : (
<div className="space-y-6">
{isDirty && (
<div className="bg-yellow-50 px-3 py-2 text-sm text-yellow-700">
{t(
"pages.config.unsaved_changes",
"You have unsaved changes.",
)}
</div>
)}
<AgentDefaultsSection form={form} onFieldChange={updateField} />
<Separator />
<RuntimeSection form={form} onFieldChange={updateField} />
<Separator />
<LauncherSection
launcherForm={launcherForm}
onFieldChange={updateLauncherField}
launcherHint={launcherHint}
disabled={saving || isLauncherLoading}
/>
<Separator />
<DevicesSection
form={form}
onFieldChange={updateField}
autoStartEnabled={autoStartEnabled}
autoStartHint={autoStartHint}
autoStartDisabled={
isAutoStartLoading ||
Boolean(autoStartError) ||
!autoStartSupported ||
saving
}
onAutoStartChange={setAutoStartEnabled}
/>
<Separator />
<AdvancedSection />
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!isDirty || saving}
>
{t("common.reset", "Reset")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
<IconDeviceFloppy className="size-4" />
{saving
? t("common.saving", "Saving...")
: t("common.save", "Save")}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,385 @@
import { IconCode } from "@tabler/icons-react"
import { Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import {
type CoreConfigForm,
DM_SCOPE_OPTIONS,
type LauncherForm,
} from "@/components/config/form-model"
import { Field, SwitchCardField } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
type UpdateCoreField = <K extends keyof CoreConfigForm>(
key: K,
value: CoreConfigForm[K],
) => void
type UpdateLauncherField = <K extends keyof LauncherForm>(
key: K,
value: LauncherForm[K],
) => void
interface AgentDefaultsSectionProps {
form: CoreConfigForm
onFieldChange: UpdateCoreField
}
export function AgentDefaultsSection({
form,
onFieldChange,
}: AgentDefaultsSectionProps) {
const { t } = useTranslation()
return (
<section className="space-y-3">
<div className="space-y-4">
<Field
label={t("pages.config.workspace", "Workspace Directory")}
hint={t(
"pages.config.workspace_hint",
"Base directory for agent file operations.",
)}
>
<Input
value={form.workspace}
onChange={(e) => onFieldChange("workspace", e.target.value)}
placeholder="~/.picoclaw/workspace"
/>
</Field>
<SwitchCardField
label={t("pages.config.restrict_workspace", "Restrict to Workspace")}
hint={t(
"pages.config.restrict_workspace_hint",
"Only allow file operations inside workspace.",
)}
checked={form.restrictToWorkspace}
onCheckedChange={(checked) =>
onFieldChange("restrictToWorkspace", checked)
}
/>
<Field
label={t("pages.config.max_tokens", "Max Tokens")}
hint={t(
"pages.config.max_tokens_hint",
"Upper token limit per model response.",
)}
>
<Input
type="number"
min={1}
value={form.maxTokens}
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.max_tool_iterations", "Max Tool Iterations")}
hint={t(
"pages.config.max_tool_iterations_hint",
"Maximum tool-call loops in a single task.",
)}
>
<Input
type="number"
min={1}
value={form.maxToolIterations}
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
/>
</Field>
<Field
label={t(
"pages.config.summarize_threshold",
"Summarize Message Threshold",
)}
hint={t(
"pages.config.summarize_threshold_hint",
"Start summarization after this many messages.",
)}
>
<Input
type="number"
min={1}
value={form.summarizeMessageThreshold}
onChange={(e) =>
onFieldChange("summarizeMessageThreshold", e.target.value)
}
/>
</Field>
<Field
label={t(
"pages.config.summarize_token_percent",
"Summarize Token Percent",
)}
hint={t(
"pages.config.summarize_token_percent_hint",
"Used when conversation summary is triggered.",
)}
>
<Input
type="number"
min={1}
max={100}
value={form.summarizeTokenPercent}
onChange={(e) =>
onFieldChange("summarizeTokenPercent", e.target.value)
}
/>
</Field>
</div>
</section>
)
}
interface RuntimeSectionProps {
form: CoreConfigForm
onFieldChange: UpdateCoreField
}
export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
const { t } = useTranslation()
const selectedDmScopeOption = DM_SCOPE_OPTIONS.find(
(scope) => scope.value === form.dmScope,
)
return (
<section className="space-y-3">
<div className="space-y-4">
<Field
label={t("pages.config.session_scope", "Session Scope")}
hint={t(
"pages.config.session_scope_hint",
"How chat context is isolated across peers/channels.",
)}
>
<Select
value={form.dmScope}
onValueChange={(value) => onFieldChange("dmScope", value)}
>
<SelectTrigger>
<SelectValue>
{selectedDmScopeOption
? t(
selectedDmScopeOption.labelKey,
selectedDmScopeOption.labelDefault,
)
: form.dmScope}
</SelectValue>
</SelectTrigger>
<SelectContent>
{DM_SCOPE_OPTIONS.map((scope) => (
<SelectItem key={scope.value} value={scope.value}>
<div className="flex flex-col gap-0.5">
<span className="font-medium">
{t(scope.labelKey, scope.labelDefault)}
</span>
<span className="text-muted-foreground text-xs">
{t(scope.descKey, scope.descDefault)}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<SwitchCardField
label={t("pages.config.heartbeat_enabled", "Heartbeat")}
hint={t(
"pages.config.heartbeat_enabled_hint",
"Send periodic heartbeat messages.",
)}
checked={form.heartbeatEnabled}
onCheckedChange={(checked) =>
onFieldChange("heartbeatEnabled", checked)
}
/>
{form.heartbeatEnabled && (
<Field
label={t(
"pages.config.heartbeat_interval",
"Heartbeat Interval (minutes)",
)}
hint={t(
"pages.config.heartbeat_interval_hint",
"Interval in minutes between heartbeat signals.",
)}
>
<Input
type="number"
min={1}
value={form.heartbeatInterval}
onChange={(e) =>
onFieldChange("heartbeatInterval", e.target.value)
}
/>
</Field>
)}
</div>
</section>
)
}
interface LauncherSectionProps {
launcherForm: LauncherForm
onFieldChange: UpdateLauncherField
launcherHint: string
disabled: boolean
}
export function LauncherSection({
launcherForm,
onFieldChange,
launcherHint,
disabled,
}: LauncherSectionProps) {
const { t } = useTranslation()
return (
<section className="space-y-3">
<div className="space-y-4">
<Field
label={t("pages.config.server_port", "Service Port")}
hint={t(
"pages.config.server_port_hint",
"HTTP port used by PicoClaw Web.",
)}
>
<Input
type="number"
min={1}
max={65535}
value={launcherForm.port}
disabled={disabled}
onChange={(e) => onFieldChange("port", e.target.value)}
/>
</Field>
<SwitchCardField
label={t("pages.config.lan_access", "Enable LAN Access")}
hint={t(
"pages.config.lan_access_hint",
"Allow access from other devices on your local network.",
)}
checked={launcherForm.publicAccess}
disabled={disabled}
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
/>
<Field
label={t("pages.config.allowed_cidrs", "Allowed Network CIDRs")}
hint={t(
"pages.config.allowed_cidrs_hint",
"Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
)}
>
<Textarea
value={launcherForm.allowedCIDRsText}
disabled={disabled}
placeholder={t(
"pages.config.allowed_cidrs_placeholder",
"192.168.1.0/24\n10.0.0.0/8",
)}
className="min-h-[88px]"
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
/>
</Field>
<p className="text-muted-foreground text-xs">{launcherHint}</p>
</div>
</section>
)
}
interface DevicesSectionProps {
form: CoreConfigForm
onFieldChange: UpdateCoreField
autoStartEnabled: boolean
autoStartHint: string
autoStartDisabled: boolean
onAutoStartChange: (checked: boolean) => void
}
export function DevicesSection({
form,
onFieldChange,
autoStartEnabled,
autoStartHint,
autoStartDisabled,
onAutoStartChange,
}: DevicesSectionProps) {
const { t } = useTranslation()
return (
<section className="space-y-3">
<div className="space-y-4">
<SwitchCardField
label={t("pages.config.devices_enabled", "Enable Devices")}
hint={t(
"pages.config.devices_enabled_hint",
"Enable hardware-device integrations.",
)}
checked={form.devicesEnabled}
onCheckedChange={(checked) =>
onFieldChange("devicesEnabled", checked)
}
/>
<SwitchCardField
label={t("pages.config.monitor_usb", "Monitor USB")}
hint={t(
"pages.config.monitor_usb_hint",
"Watch USB plug/unplug events when devices are enabled.",
)}
checked={form.monitorUSB}
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
/>
<SwitchCardField
label={t("pages.config.autostart_label", "Launch at Login")}
hint={autoStartHint}
checked={autoStartEnabled}
disabled={autoStartDisabled}
onCheckedChange={onAutoStartChange}
/>
</div>
</section>
)
}
export function AdvancedSection() {
const { t } = useTranslation()
return (
<section className="space-y-3">
<p className="text-muted-foreground text-sm">
{t(
"pages.config.advanced_desc",
"Open the raw JSON page to edit every field directly.",
)}
</p>
<div>
<Button variant="outline" asChild>
<Link to="/config/raw">
<IconCode className="size-4" />
{t("pages.config.open_raw", "Raw Config")}
</Link>
</Button>
</div>
</section>
)
}

View file

@ -0,0 +1,172 @@
export type JsonRecord = Record<string, unknown>
export interface CoreConfigForm {
workspace: string
restrictToWorkspace: boolean
maxTokens: string
maxToolIterations: string
summarizeMessageThreshold: string
summarizeTokenPercent: string
dmScope: string
heartbeatEnabled: boolean
heartbeatInterval: string
devicesEnabled: boolean
monitorUSB: boolean
}
export interface LauncherForm {
port: string
publicAccess: boolean
allowedCIDRsText: string
}
export const DM_SCOPE_OPTIONS = [
{
value: "per-channel-peer",
labelKey: "pages.config.session_scope_per_channel_peer",
labelDefault: "Per Channel + Peer",
descKey: "pages.config.session_scope_per_channel_peer_desc",
descDefault: "Separate context for each user in each channel.",
},
{
value: "per-channel",
labelKey: "pages.config.session_scope_per_channel",
labelDefault: "Per Channel",
descKey: "pages.config.session_scope_per_channel_desc",
descDefault: "One shared context per channel.",
},
{
value: "per-peer",
labelKey: "pages.config.session_scope_per_peer",
labelDefault: "Per Peer",
descKey: "pages.config.session_scope_per_peer_desc",
descDefault: "One context per user across channels.",
},
{
value: "global",
labelKey: "pages.config.session_scope_global",
labelDefault: "Global",
descKey: "pages.config.session_scope_global_desc",
descDefault: "All messages share one global context.",
},
] as const
export const EMPTY_FORM: CoreConfigForm = {
workspace: "",
restrictToWorkspace: true,
maxTokens: "32768",
maxToolIterations: "50",
summarizeMessageThreshold: "20",
summarizeTokenPercent: "75",
dmScope: "per-channel-peer",
heartbeatEnabled: true,
heartbeatInterval: "30",
devicesEnabled: false,
monitorUSB: true,
}
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800",
publicAccess: false,
allowedCIDRsText: "",
}
function asRecord(value: unknown): JsonRecord {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonRecord
}
return {}
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asBool(value: unknown): boolean {
return value === true
}
function asNumberString(value: unknown, fallback: string): string {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value)
}
if (typeof value === "string" && value.trim() !== "") {
return value
}
return fallback
}
export function buildFormFromConfig(config: unknown): CoreConfigForm {
const root = asRecord(config)
const agents = asRecord(root.agents)
const defaults = asRecord(agents.defaults)
const session = asRecord(root.session)
const heartbeat = asRecord(root.heartbeat)
const devices = asRecord(root.devices)
return {
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
restrictToWorkspace:
defaults.restrict_to_workspace === undefined
? EMPTY_FORM.restrictToWorkspace
: asBool(defaults.restrict_to_workspace),
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
maxToolIterations: asNumberString(
defaults.max_tool_iterations,
EMPTY_FORM.maxToolIterations,
),
summarizeMessageThreshold: asNumberString(
defaults.summarize_message_threshold,
EMPTY_FORM.summarizeMessageThreshold,
),
summarizeTokenPercent: asNumberString(
defaults.summarize_token_percent,
EMPTY_FORM.summarizeTokenPercent,
),
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,
heartbeatEnabled:
heartbeat.enabled === undefined
? EMPTY_FORM.heartbeatEnabled
: asBool(heartbeat.enabled),
heartbeatInterval: asNumberString(
heartbeat.interval,
EMPTY_FORM.heartbeatInterval,
),
devicesEnabled:
devices.enabled === undefined
? EMPTY_FORM.devicesEnabled
: asBool(devices.enabled),
monitorUSB:
devices.monitor_usb === undefined
? EMPTY_FORM.monitorUSB
: asBool(devices.monitor_usb),
}
}
export function parseIntField(
rawValue: string,
label: string,
options: { min?: number; max?: number } = {},
): number {
const value = Number(rawValue)
if (!Number.isInteger(value)) {
throw new Error(`${label} must be an integer.`)
}
if (options.min !== undefined && value < options.min) {
throw new Error(`${label} must be >= ${options.min}.`)
}
if (options.max !== undefined && value > options.max) {
throw new Error(`${label} must be <= ${options.max}.`)
}
return value
}
export function parseCIDRText(raw: string): string[] {
if (!raw.trim()) {
return []
}
return raw
.split(/[\n,]/)
.map((v) => v.trim())
.filter((v) => v.length > 0)
}

View file

@ -0,0 +1,230 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
export function RawJsonPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: config, isLoading } = useQuery({
queryKey: ["config"],
queryFn: async () => {
const res = await fetch("/api/config")
if (!res.ok) {
throw new Error("Failed to fetch config")
}
return res.json()
},
})
const mutation = useMutation({
mutationFn: async (newConfig: string) => {
const res = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: newConfig,
})
if (!res.ok) {
throw new Error("Failed to save config")
}
},
onSuccess: (_, submittedConfig) => {
toast.success(
t("pages.config.save_success", "Configuration saved successfully."),
)
try {
const savedConfig = JSON.parse(submittedConfig)
setLastSavedConfig(savedConfig)
setIsDirty(false)
queryClient.invalidateQueries({ queryKey: ["config"] })
} catch {
queryClient.invalidateQueries({ queryKey: ["config"] })
}
},
onError: () => {
toast.error(t("pages.config.save_error", "Failed to save configuration."))
},
})
const [editorValue, setEditorValue] = useState("")
const [isDirty, setIsDirty] = useState(false)
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
string,
unknown
> | null>(null)
const effectiveEditorValue =
editorValue || (config ? JSON.stringify(config, null, 2) : "")
const handleSave = () => {
try {
JSON.parse(effectiveEditorValue)
mutation.mutate(effectiveEditorValue)
} catch (error) {
toast.error(
t(
"pages.config.invalid_json",
error instanceof Error ? error.message : "Invalid JSON format.",
),
)
}
}
const handleFormat = () => {
try {
const formatted = JSON.stringify(
JSON.parse(effectiveEditorValue),
null,
2,
)
setEditorValue(formatted)
toast.success(
t("pages.config.format_success", "JSON formatted successfully."),
)
} catch (error) {
toast.error(
t(
"pages.config.format_error",
error instanceof Error ? error.message : "Invalid JSON format.",
),
)
}
}
const [showResetDialog, setShowResetDialog] = useState(false)
const confirmReset = () => {
if (lastSavedConfig) {
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
} else if (config) {
setEditorValue(JSON.stringify(config, null, 2))
}
setIsDirty(false)
toast.info(
t(
"pages.config.reset_success",
"Changes have been reset to the last saved state.",
),
)
setShowResetDialog(false)
}
return (
<Card>
<CardHeader>
<CardTitle>
{t("pages.config.raw_json_title", "Raw JSON Configuration")}
</CardTitle>
<CardDescription>
{t(
"pages.config.raw_json_desc",
"Advanced users can directly edit the raw JSON configuration below.",
)}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<p>{t("labels.loading", "Loading...")}</p>
</div>
) : (
<div className="space-y-3">
{isDirty && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes", "You have unsaved changes.")}
</div>
)}
<div className="bg-muted/30 relative rounded-lg border">
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
<Textarea
value={effectiveEditorValue}
onChange={(e) => {
setEditorValue(e.target.value)
setIsDirty(true)
}}
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
placeholder={t(
"pages.config.json_placeholder",
"Enter valid JSON configuration...",
)}
/>
</ScrollArea>
</div>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={handleFormat}
disabled={mutation.isPending}
>
{t("pages.config.format", "Format")}
</Button>
<AlertDialog
open={showResetDialog}
onOpenChange={setShowResetDialog}
>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={!isDirty}
onClick={() => setShowResetDialog(true)}
>
{t("common.reset", "Reset")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.config.reset_confirm_title", "Reset Changes")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"pages.config.reset_confirm_desc",
"Are you sure you want to reset your unsaved changes back to the last saved state?",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}>
{t("common.confirm", "Confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending
? t("common.saving", "Saving...")
: t("common.save", "Save")}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -83,6 +83,7 @@ interface SwitchCardFieldProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
ariaLabel?: string
disabled?: boolean
children?: ReactNode
}
@ -93,6 +94,7 @@ export function SwitchCardField({
checked,
onCheckedChange,
ariaLabel,
disabled,
children,
}: SwitchCardFieldProps) {
return (
@ -109,6 +111,7 @@ export function SwitchCardField({
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
aria-label={ariaLabel ?? label}
/>
</div>

View file

@ -25,7 +25,30 @@ export interface ChatMessage {
type ConnectionState = "disconnected" | "connecting" | "connected" | "error"
function generateSessionId(): string {
return crypto.randomUUID()
const webCrypto = globalThis.crypto
if (webCrypto && typeof webCrypto.randomUUID === "function") {
return webCrypto.randomUUID()
}
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
const bytes = new Uint8Array(16)
webCrypto.getRandomValues(bytes)
// RFC4122 v4: set version and variant bits.
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
return (
`${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
`${hex[4]}${hex[5]}-` +
`${hex[6]}${hex[7]}-` +
`${hex[8]}${hex[9]}-` +
`${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
)
}
return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
}
const UNIX_MS_THRESHOLD = 1e12

View file

@ -373,6 +373,62 @@
},
"config": {
"description": "System configuration and preferences.",
"visual_title": "Core Configuration",
"visual_desc": "Edit key runtime options here. Use Raw Config for full JSON editing.",
"load_error": "Failed to load configuration. Please refresh and try again.",
"section_agents": "Agent Defaults",
"section_runtime": "Runtime",
"section_devices": "Devices",
"workspace": "Workspace Directory",
"workspace_hint": "Base directory for agent file operations.",
"restrict_workspace": "Restrict to Workspace",
"restrict_workspace_hint": "Only allow file operations inside workspace.",
"max_tokens": "Max Tokens",
"max_tokens_hint": "Upper token limit per model response.",
"max_tool_iterations": "Max Tool Iterations",
"max_tool_iterations_hint": "Maximum tool-call loops in a single task.",
"summarize_threshold": "Summarize Message Threshold",
"summarize_threshold_hint": "Start summarization after this many messages.",
"summarize_token_percent": "Summarize Token Percent",
"summarize_token_percent_hint": "Used when conversation summary is triggered.",
"session_scope": "Session Scope",
"session_scope_hint": "How chat context is isolated across peers/channels.",
"session_scope_per_channel_peer": "Per Channel + Peer",
"session_scope_per_channel_peer_desc": "Separate context for each user in each channel.",
"session_scope_per_channel": "Per Channel",
"session_scope_per_channel_desc": "One shared context per channel.",
"session_scope_per_peer": "Per Peer",
"session_scope_per_peer_desc": "One context per user across channels.",
"session_scope_global": "Global",
"session_scope_global_desc": "All messages share one global context.",
"heartbeat_enabled": "Heartbeat",
"heartbeat_enabled_hint": "Send periodic heartbeat messages.",
"heartbeat_interval": "Heartbeat Interval (minutes)",
"heartbeat_interval_hint": "Interval in minutes between heartbeat signals.",
"devices_enabled": "Enable Devices",
"devices_enabled_hint": "Enable hardware-device integrations.",
"monitor_usb": "Monitor USB",
"monitor_usb_hint": "Watch USB plug/unplug events when devices are enabled.",
"autostart_label": "Launch at Login",
"autostart_hint": "Start PicoClaw Web automatically when you log in.",
"autostart_unsupported": "Launch at login is not supported on this platform.",
"autostart_load_error": "Failed to load launch-at-login status.",
"server_port": "Service Port",
"server_port_hint": "HTTP port used by PicoClaw Web.",
"lan_access": "Enable LAN Access",
"lan_access_hint": "Allow access from other devices on your local network.",
"allowed_cidrs": "Allowed Network CIDRs",
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"launcher_load_error": "Failed to load service parameters.",
"launcher_restart_hint": "Service parameter changes apply after restarting PicoClaw Web.",
"autostart_enable_success": "Launch at login has been enabled.",
"autostart_disable_success": "Launch at login has been disabled.",
"autostart_update_error": "Failed to update launch-at-login setting.",
"advanced_title": "Need full configuration access?",
"advanced_desc": "Open the raw JSON page to edit every field directly.",
"open_raw": "Raw Config",
"back_to_visual": "Visual Config",
"raw_json_title": "Raw JSON Configuration",
"raw_json_desc": "Advanced users can directly edit the raw JSON configuration below.",
"json_placeholder": "Enter valid JSON configuration...",
@ -385,7 +441,8 @@
"format_success": "JSON formatted successfully.",
"format_error": "Invalid JSON format.",
"format": "Format",
"lose_unsaved_changes": "You have unsaved changes. Are you sure you want to reset and lose these changes?"
"lose_unsaved_changes": "You have unsaved changes. Are you sure you want to reset and lose these changes?",
"unsaved_changes": "You have unsaved changes."
},
"logs": {
"description": "System logs and monitoring."

View file

@ -373,6 +373,62 @@
},
"config": {
"description": "系统配置和偏好设置。",
"visual_title": "核心配置",
"visual_desc": "这里可编辑关键运行配置;若需完整字段请使用原始配置页。",
"load_error": "加载配置失败,请刷新后重试。",
"section_agents": "智能体默认设置",
"section_runtime": "运行时",
"section_devices": "设备功能",
"workspace": "工作目录",
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
"restrict_workspace": "限制工作目录访问",
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
"max_tokens": "最大 Token 数",
"max_tokens_hint": "单次模型响应允许的最大 Token 数。",
"max_tool_iterations": "最大工具迭代次数",
"max_tool_iterations_hint": "单个任务中允许的工具调用循环上限。",
"summarize_threshold": "触发摘要的消息阈值",
"summarize_threshold_hint": "消息数量达到该值后开始触发摘要。",
"summarize_token_percent": "摘要目标 Token 百分比",
"summarize_token_percent_hint": "在触发会话摘要时使用。",
"session_scope": "会话隔离范围",
"session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文。",
"session_scope_per_channel_peer": "按频道+用户隔离",
"session_scope_per_channel_peer_desc": "同一频道内不同用户使用独立上下文。",
"session_scope_per_channel": "按频道隔离",
"session_scope_per_channel_desc": "同一频道内共享一个上下文。",
"session_scope_per_peer": "按用户隔离",
"session_scope_per_peer_desc": "同一用户跨频道共享一个上下文。",
"session_scope_global": "全局共享",
"session_scope_global_desc": "所有消息共用一个全局上下文。",
"heartbeat_enabled": "心跳开关",
"heartbeat_enabled_hint": "按间隔发送系统心跳。",
"heartbeat_interval": "心跳间隔(分钟)",
"heartbeat_interval_hint": "两次心跳发送之间的分钟间隔。",
"devices_enabled": "启用设备功能",
"devices_enabled_hint": "启用与本机硬件设备相关的能力。",
"monitor_usb": "监听 USB",
"monitor_usb_hint": "在启用设备功能时,监听 USB 插拔事件。",
"autostart_label": "开机自启",
"autostart_hint": "登录系统后自动启动 PicoClaw Web。",
"autostart_unsupported": "当前平台不支持开机自启。",
"autostart_load_error": "加载开机自启状态失败。",
"server_port": "服务端口",
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口。",
"lan_access": "启用局域网访问",
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
"allowed_cidrs": "允许访问网段CIDR",
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"launcher_load_error": "加载服务参数失败。",
"launcher_restart_hint": "服务参数变更需重启 PicoClaw Web 后生效。",
"autostart_enable_success": "已开启开机自启。",
"autostart_disable_success": "已关闭开机自启。",
"autostart_update_error": "更新开机自启设置失败。",
"advanced_title": "需要完整配置能力?",
"advanced_desc": "可打开原始 JSON 页面直接编辑全部字段。",
"open_raw": "原始配置",
"back_to_visual": "可视化配置",
"raw_json_title": "原始 JSON 配置",
"raw_json_desc": "高级用户可以直接编辑下方的原始 JSON 配置。",
"json_placeholder": "请输入有效的 JSON 配置...",

View file

@ -16,6 +16,7 @@ import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config'
import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ConfigRawRouteImport } from './routes/config.raw'
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
const ProvidersRoute = ProvidersRouteImport.update({
@ -53,6 +54,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ConfigRawRoute = ConfigRawRouteImport.update({
id: '/raw',
path: '/raw',
getParentRoute: () => ConfigRoute,
} as any)
const ChannelsNameRoute = ChannelsNameRouteImport.update({
id: '/$name',
path: '/$name',
@ -62,33 +68,36 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/channels': typeof ChannelsRouteRouteWithChildren
'/config': typeof ConfigRoute
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute
'/channels/$name': typeof ChannelsNameRoute
'/config/raw': typeof ConfigRawRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/channels': typeof ChannelsRouteRouteWithChildren
'/config': typeof ConfigRoute
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute
'/channels/$name': typeof ChannelsNameRoute
'/config/raw': typeof ConfigRawRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/channels': typeof ChannelsRouteRouteWithChildren
'/config': typeof ConfigRoute
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/providers': typeof ProvidersRoute
'/channels/$name': typeof ChannelsNameRoute
'/config/raw': typeof ConfigRawRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -101,6 +110,7 @@ export interface FileRouteTypes {
| '/models'
| '/providers'
| '/channels/$name'
| '/config/raw'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -111,6 +121,7 @@ export interface FileRouteTypes {
| '/models'
| '/providers'
| '/channels/$name'
| '/config/raw'
id:
| '__root__'
| '/'
@ -121,12 +132,13 @@ export interface FileRouteTypes {
| '/models'
| '/providers'
| '/channels/$name'
| '/config/raw'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
ConfigRoute: typeof ConfigRoute
ConfigRoute: typeof ConfigRouteWithChildren
CredentialsRoute: typeof CredentialsRoute
LogsRoute: typeof LogsRoute
ModelsRoute: typeof ModelsRoute
@ -184,6 +196,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/config/raw': {
id: '/config/raw'
path: '/raw'
fullPath: '/config/raw'
preLoaderRoute: typeof ConfigRawRouteImport
parentRoute: typeof ConfigRoute
}
'/channels/$name': {
id: '/channels/$name'
path: '/$name'
@ -206,10 +225,21 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
ChannelsRouteRouteChildren,
)
interface ConfigRouteChildren {
ConfigRawRoute: typeof ConfigRawRoute
}
const ConfigRouteChildren: ConfigRouteChildren = {
ConfigRawRoute: ConfigRawRoute,
}
const ConfigRouteWithChildren =
ConfigRoute._addFileChildren(ConfigRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
ConfigRoute: ConfigRoute,
ConfigRoute: ConfigRouteWithChildren,
CredentialsRoute: CredentialsRoute,
LogsRoute: LogsRoute,
ModelsRoute: ModelsRoute,

View file

@ -0,0 +1,36 @@
import { IconAdjustments } from "@tabler/icons-react"
import { Link, createFileRoute } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { RawJsonPanel } from "@/components/config/raw-json-panel"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
export const Route = createFileRoute("/config/raw")({
component: RawConfigPage,
})
function RawConfigPage() {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("pages.config.raw_json_title", "Raw JSON Configuration")}
>
<Button variant="outline" asChild>
<Link to="/config">
<IconAdjustments className="size-4" />
{t("pages.config.back_to_visual", "Visual Config")}
</Link>
</Button>
</PageHeader>
<div className="flex-1 overflow-auto p-3 lg:p-6">
<div className="mx-auto max-w-4xl">
<RawJsonPanel />
</div>
</div>
</div>
)
}

View file

@ -1,258 +1,19 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router"
import { PageHeader } from "@/components/page-header"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
import { ConfigPage } from "@/components/config/config-page"
export const Route = createFileRoute("/config")({
component: ConfigPage,
component: ConfigRouteLayout,
})
function ConfigPage() {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.config", "Config")} />
<div className="flex-1 overflow-auto p-3 lg:p-6">
<div className="mx-auto max-w-4xl">
<RawJsonPanel />
</div>
</div>
</div>
)
}
function RawJsonPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: config, isLoading } = useQuery({
queryKey: ["config"],
queryFn: async () => {
const res = await fetch("/api/config")
if (!res.ok) {
throw new Error("Failed to fetch config")
}
return res.json()
},
function ConfigRouteLayout() {
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const mutation = useMutation({
mutationFn: async (newConfig: string) => {
const res = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: newConfig,
})
if (!res.ok) {
throw new Error("Failed to save config")
}
},
onSuccess: (_, submittedConfig) => {
toast.success(
t("pages.config.save_success", "Configuration saved successfully."),
)
// Update last saved config and reset dirty state
try {
const savedConfig = JSON.parse(submittedConfig)
setLastSavedConfig(savedConfig)
setIsDirty(false)
// Important: Invalidate the query to refresh the cached data
queryClient.invalidateQueries({ queryKey: ["config"] })
} catch {
// If JSON parsing fails, invalidate to get fresh data
queryClient.invalidateQueries({ queryKey: ["config"] })
}
},
onError: () => {
toast.error(t("pages.config.save_error", "Failed to save configuration."))
},
})
const [editorValue, setEditorValue] = useState("")
const [isDirty, setIsDirty] = useState(false)
// Store the last saved config to detect changes
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
string,
unknown
> | null>(null)
const effectiveEditorValue =
editorValue || (config ? JSON.stringify(config, null, 2) : "")
const handleSave = () => {
try {
// Validate JSON before saving
JSON.parse(effectiveEditorValue)
mutation.mutate(effectiveEditorValue)
} catch (error) {
toast.error(
t(
"pages.config.invalid_json",
error instanceof Error ? error.message : "Invalid JSON format.",
),
)
}
if (pathname === "/config") {
return <ConfigPage />
}
const handleFormat = () => {
try {
const formatted = JSON.stringify(
JSON.parse(effectiveEditorValue),
null,
2,
)
setEditorValue(formatted)
toast.success(
t("pages.config.format_success", "JSON formatted successfully."),
)
} catch (error) {
toast.error(
t(
"pages.config.format_error",
error instanceof Error ? error.message : "Invalid JSON format.",
),
)
}
}
const [showResetDialog, setShowResetDialog] = useState(false)
const confirmReset = () => {
// Reset editor content to the last saved configuration
if (lastSavedConfig) {
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
} else if (config) {
// Fallback to current config if no last saved config
setEditorValue(JSON.stringify(config, null, 2))
}
setIsDirty(false)
toast.info(
t(
"pages.config.reset_success",
"Changes have been reset to the last saved state.",
),
)
setShowResetDialog(false)
}
return (
<Card>
<CardHeader>
<CardTitle>
{t("pages.config.raw_json_title", "Raw JSON Configuration")}
</CardTitle>
<CardDescription>
{t(
"pages.config.raw_json_desc",
"Advanced users can directly edit the raw JSON configuration below.",
)}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<p>{t("labels.loading", "Loading...")}</p>
</div>
) : (
<div className="space-y-3">
{isDirty && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes", "You have unsaved changes.")}
</div>
)}
<div className="bg-muted/30 relative rounded-lg border">
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
<Textarea
value={effectiveEditorValue}
onChange={(e) => {
setEditorValue(e.target.value)
setIsDirty(true)
}}
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
placeholder={t(
"pages.config.json_placeholder",
"Enter valid JSON configuration...",
)}
/>
</ScrollArea>
</div>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={handleFormat}
disabled={mutation.isPending}
>
{t("pages.config.format", "Format")}
</Button>
<AlertDialog
open={showResetDialog}
onOpenChange={setShowResetDialog}
>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={!isDirty}
onClick={() => setShowResetDialog(true)}
>
{t("common.reset", "Reset")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.config.reset_confirm_title", "Reset Changes")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"pages.config.reset_confirm_desc",
"Are you sure you want to reset your unsaved changes back to the last saved state?",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}>
{t("common.confirm", "Confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending
? t("common.saving", "Saving...")
: t("common.save", "Save")}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)
return <Outlet />
}