mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge branch 'main' into t3
This commit is contained in:
+104
-18
@@ -26,15 +26,16 @@ import (
|
||||
|
||||
// gateway holds the state for the managed gateway process.
|
||||
var gateway = struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
owned bool // true if we started the process, false if we attached to an existing one
|
||||
bootDefaultModel string
|
||||
runtimeStatus string
|
||||
startupDeadline time.Time
|
||||
logs *LogBuffer
|
||||
pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json
|
||||
picoToken string // cached pico token from config (for proxy auth validation)
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
owned bool // true if we started the process, false if we attached to an existing one
|
||||
bootDefaultModel string
|
||||
bootConfigSignature string
|
||||
runtimeStatus string
|
||||
startupDeadline time.Time
|
||||
logs *LogBuffer
|
||||
pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json
|
||||
picoToken string // cached pico token from config (for proxy auth validation)
|
||||
}{
|
||||
runtimeStatus: "stopped",
|
||||
logs: NewLogBuffer(200),
|
||||
@@ -234,14 +235,93 @@ func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig
|
||||
return modelCfg
|
||||
}
|
||||
|
||||
func gatewayRestartRequired(configDefaultModel, bootDefaultModel, gatewayStatus string) bool {
|
||||
func computeConfigSignature(cfg *config.Config) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
defaultModel := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
if defaultModel != "" {
|
||||
parts = append(parts, "model:"+defaultModel)
|
||||
}
|
||||
toolSignatures := []string{}
|
||||
if cfg.Tools.ReadFile.Enabled {
|
||||
toolSignatures = append(toolSignatures, "read_file")
|
||||
}
|
||||
if cfg.Tools.WriteFile.Enabled {
|
||||
toolSignatures = append(toolSignatures, "write_file")
|
||||
}
|
||||
if cfg.Tools.ListDir.Enabled {
|
||||
toolSignatures = append(toolSignatures, "list_dir")
|
||||
}
|
||||
if cfg.Tools.EditFile.Enabled {
|
||||
toolSignatures = append(toolSignatures, "edit_file")
|
||||
}
|
||||
if cfg.Tools.AppendFile.Enabled {
|
||||
toolSignatures = append(toolSignatures, "append_file")
|
||||
}
|
||||
if cfg.Tools.Exec.Enabled {
|
||||
toolSignatures = append(toolSignatures, "exec")
|
||||
}
|
||||
if cfg.Tools.Cron.Enabled {
|
||||
toolSignatures = append(toolSignatures, "cron")
|
||||
}
|
||||
if cfg.Tools.Web.Enabled {
|
||||
toolSignatures = append(toolSignatures, "web")
|
||||
}
|
||||
if cfg.Tools.WebFetch.Enabled {
|
||||
toolSignatures = append(toolSignatures, "web_fetch")
|
||||
}
|
||||
if cfg.Tools.Message.Enabled {
|
||||
toolSignatures = append(toolSignatures, "message")
|
||||
}
|
||||
if cfg.Tools.SendFile.Enabled {
|
||||
toolSignatures = append(toolSignatures, "send_file")
|
||||
}
|
||||
if cfg.Tools.FindSkills.Enabled {
|
||||
toolSignatures = append(toolSignatures, "find_skills")
|
||||
}
|
||||
if cfg.Tools.InstallSkill.Enabled {
|
||||
toolSignatures = append(toolSignatures, "install_skill")
|
||||
}
|
||||
if cfg.Tools.Spawn.Enabled {
|
||||
toolSignatures = append(toolSignatures, "spawn")
|
||||
}
|
||||
if cfg.Tools.SpawnStatus.Enabled {
|
||||
toolSignatures = append(toolSignatures, "spawn_status")
|
||||
}
|
||||
if cfg.Tools.I2C.Enabled {
|
||||
toolSignatures = append(toolSignatures, "i2c")
|
||||
}
|
||||
if cfg.Tools.SPI.Enabled {
|
||||
toolSignatures = append(toolSignatures, "spi")
|
||||
}
|
||||
if cfg.Tools.MCP.Enabled {
|
||||
toolSignatures = append(toolSignatures, "mcp")
|
||||
}
|
||||
if cfg.Tools.MCP.Discovery.Enabled {
|
||||
toolSignatures = append(toolSignatures, "mcp_discovery")
|
||||
}
|
||||
if cfg.Tools.MCP.Discovery.UseRegex {
|
||||
toolSignatures = append(toolSignatures, "mcp_discovery_regex")
|
||||
}
|
||||
if cfg.Tools.MCP.Discovery.UseBM25 {
|
||||
toolSignatures = append(toolSignatures, "mcp_discovery_bm25")
|
||||
}
|
||||
if len(toolSignatures) > 0 {
|
||||
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
|
||||
if gatewayStatus != "running" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(configDefaultModel) == "" || strings.TrimSpace(bootDefaultModel) == "" {
|
||||
if bootSignature == "" || currentSignature == "" {
|
||||
return false
|
||||
}
|
||||
return configDefaultModel != bootDefaultModel
|
||||
return bootSignature != currentSignature
|
||||
}
|
||||
|
||||
func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
|
||||
@@ -285,10 +365,11 @@ func attachToGatewayProcessLocked(pid int, cfg *config.Config) error {
|
||||
gateway.owned = false // We didn't start this process
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
|
||||
// Update bootDefaultModel from config
|
||||
// Update bootDefaultModel and bootConfigSignature from config
|
||||
if cfg != nil {
|
||||
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
gateway.bootDefaultModel = defaultModelName
|
||||
gateway.bootConfigSignature = computeConfigSignature(cfg)
|
||||
}
|
||||
|
||||
logger.InfoC("gateway", fmt.Sprintf("Attached to gateway process (PID: %d)", pid))
|
||||
@@ -485,6 +566,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
||||
gateway.cmd = cmd
|
||||
gateway.owned = true // We started this process
|
||||
gateway.bootDefaultModel = defaultModelName
|
||||
gateway.bootConfigSignature = computeConfigSignature(cfg)
|
||||
setGatewayRuntimeStatusLocked(initialStatus)
|
||||
pid = cmd.Process.Pid
|
||||
logger.InfoC("gateway", fmt.Sprintf("Started picoclaw gateway (PID: %d) from %s", pid, execPath))
|
||||
@@ -505,6 +587,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
||||
if gateway.cmd == cmd {
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
gateway.bootConfigSignature = ""
|
||||
if gateway.runtimeStatus != "restarting" {
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
}
|
||||
@@ -790,7 +873,7 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) gatewayStatusData() map[string]any {
|
||||
data := map[string]any{}
|
||||
configDefaultModel := ""
|
||||
var configDefaultModel string
|
||||
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||
if cfgErr == nil && cfg != nil {
|
||||
configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||
@@ -852,11 +935,14 @@ func (h *Handler) gatewayStatusData() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
bootDefaultModel, _ := data["boot_default_model"].(string)
|
||||
gatewayStatus, _ := data["gateway_status"].(string)
|
||||
data["gateway_restart_required"] = gatewayRestartRequired(
|
||||
configDefaultModel,
|
||||
bootDefaultModel,
|
||||
currentConfigSignature := computeConfigSignature(cfg)
|
||||
gateway.mu.Lock()
|
||||
bootConfigSignature := gateway.bootConfigSignature
|
||||
gateway.mu.Unlock()
|
||||
data["gateway_restart_required"] = gatewayRestartRequiredBySignature(
|
||||
bootConfigSignature,
|
||||
currentConfigSignature,
|
||||
gatewayStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ func resetGatewayTestState(t *testing.T) {
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
gateway.bootConfigSignature = ""
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
gateway.mu.Unlock()
|
||||
})
|
||||
@@ -499,9 +500,11 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
|
||||
t.Fatalf("FindProcess() error = %v", err)
|
||||
}
|
||||
|
||||
bootSignature := computeConfigSignature(cfg)
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = &exec.Cmd{Process: process}
|
||||
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
|
||||
gateway.bootConfigSignature = bootSignature
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
@@ -545,6 +548,188 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusRequiresRestartAfterToolChange(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].SetAPIKey("test-key")
|
||||
cfg.Tools.WriteFile.Enabled = true
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
process, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("FindProcess() error = %v", err)
|
||||
}
|
||||
|
||||
bootSignature := computeConfigSignature(cfg)
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = &exec.Cmd{Process: process}
|
||||
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
|
||||
gateway.bootConfigSignature = bootSignature
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
updatedCfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
updatedCfg.Tools.WriteFile.Enabled = false
|
||||
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), 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_status"]; got != "running" {
|
||||
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||
}
|
||||
if got := body["gateway_restart_required"]; got != true {
|
||||
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(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].SetAPIKey("test-key")
|
||||
cfg.Agents.Defaults.MaxTokens = 1000
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
process, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("FindProcess() error = %v", err)
|
||||
}
|
||||
|
||||
bootSignature := computeConfigSignature(cfg)
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = &exec.Cmd{Process: process}
|
||||
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
|
||||
gateway.bootConfigSignature = bootSignature
|
||||
setGatewayRuntimeStatusLocked("running")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
updatedCfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
updatedCfg.Agents.Defaults.MaxTokens = 2000
|
||||
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), 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_status"]; got != "running" {
|
||||
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||
}
|
||||
if got := body["gateway_restart_required"]; got != false {
|
||||
t.Fatalf("gateway_restart_required = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusNoRestartRequiredWhenNotRunning(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].SetAPIKey("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)
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = nil
|
||||
gateway.bootDefaultModel = ""
|
||||
gateway.bootConfigSignature = ""
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
gateway.mu.Unlock()
|
||||
|
||||
updatedCfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
updatedCfg.Agents.Defaults.ModelName = "different-model"
|
||||
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||
return nil, errors.New("no gateway running")
|
||||
}
|
||||
|
||||
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_status"]; got != "stopped" {
|
||||
t.Fatalf("gateway_status = %#v, want %q", got, "stopped")
|
||||
}
|
||||
if got := body["gateway_restart_required"]; got != false {
|
||||
t.Fatalf("gateway_restart_required = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {
|
||||
resetGatewayTestState(t)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/components/config/form-model"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -281,6 +282,7 @@ export function ConfigPage() {
|
||||
}
|
||||
|
||||
toast.success(t("pages.config.save_success"))
|
||||
void refreshGatewayState({ force: true })
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("pages.config.save_error"),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
export function RawConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -56,6 +57,7 @@ export function RawConfigPage() {
|
||||
} catch {
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
}
|
||||
void refreshGatewayState({ force: true })
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("pages.config.save_error"))
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
export function ToolsPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -33,6 +34,7 @@ export function ToolsPage() {
|
||||
: t("pages.agent.tools.disable_success"),
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ["tools"] })
|
||||
void refreshGatewayState({ force: true })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"restarting": "Restarting Gateway...",
|
||||
"stopping": "Stopping Gateway..."
|
||||
},
|
||||
"restartRequired": "Model changes require a gateway restart to take effect."
|
||||
"restartRequired": "Configuration changes require a gateway restart to take effect."
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"restarting": "服务重启中...",
|
||||
"stopping": "服务停止中..."
|
||||
},
|
||||
"restartRequired": "切换默认模型后需要重启服务才能生效。"
|
||||
"restartRequired": "配置变更后需要重启服务才能生效。"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
Reference in New Issue
Block a user