mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8207c1c7e6 | |||
| 27e988c484 | |||
| 08599f8736 | |||
| 5e028a847c | |||
| 172e6ebe5f | |||
| 6c8866de6f | |||
| feee0da945 | |||
| 871b2d7342 | |||
| 8529abbc91 | |||
| 7f425f1d11 | |||
| d1b10a0004 | |||
| fc28c2660a | |||
| 9b80fdf885 | |||
| 1d0220f9fd | |||
| c7d75a18f8 | |||
| cdbc9c4bd6 | |||
| 2f4f45080b | |||
| ebfa72a286 | |||
| 1211218b60 | |||
| 70fcbc5700 | |||
| 1161aee872 | |||
| 5b96923d66 | |||
| 7276a2d651 | |||
| 99582bbd91 | |||
| 3a3862340a | |||
| fb96645ea9 | |||
| a4b6cea103 |
@@ -44,3 +44,6 @@ tasks/
|
|||||||
|
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Windows Application Icon/Resource
|
||||||
|
*.syso
|
||||||
|
|||||||
+62
-1
@@ -5,7 +5,9 @@ version: 2
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./cmd/picoclaw/...
|
- go generate ./...
|
||||||
|
- go install github.com/tc-hib/go-winres@latest
|
||||||
|
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
@@ -31,12 +33,67 @@ builds:
|
|||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
goarm:
|
goarm:
|
||||||
|
- "6"
|
||||||
- "7"
|
- "7"
|
||||||
main: ./cmd/picoclaw
|
main: ./cmd/picoclaw
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher
|
||||||
|
binary: picoclaw-launcher
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
main: ./cmd/picoclaw-launcher
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher-tui
|
||||||
|
binary: picoclaw-launcher-tui
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
main: ./cmd/picoclaw-launcher-tui
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: docker/Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
@@ -72,6 +129,10 @@ archives:
|
|||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
builds:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
package_name: picoclaw
|
package_name: picoclaw
|
||||||
file_name_template: >-
|
file_name_template: >-
|
||||||
{{ .PackageName }}_
|
{{ .PackageName }}_
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package configstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
configDirName = ".picoclaw"
|
||||||
|
configFileName = "config.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConfigPath() (string, error) {
|
||||||
|
dir, err := ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, configFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigDir() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, configDirName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*picoclawconfig.Config, error) {
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return picoclawconfig.LoadConfig(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(cfg *picoclawconfig.Config) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return errors.New("config is nil")
|
||||||
|
}
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return picoclawconfig.SaveConfig(path, cfg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type appState struct {
|
||||||
|
app *tview.Application
|
||||||
|
pages *tview.Pages
|
||||||
|
stack []string
|
||||||
|
config *picoclawconfig.Config
|
||||||
|
configPath string
|
||||||
|
gatewayCmd *exec.Cmd
|
||||||
|
menus map[string]*Menu
|
||||||
|
original []byte
|
||||||
|
hasOriginal bool
|
||||||
|
backupPath string
|
||||||
|
dirty bool
|
||||||
|
logPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run() error {
|
||||||
|
applyStyles()
|
||||||
|
cfg, err := configstore.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, err := configstore.ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = picoclawconfig.DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
originalData, hasOriginal := loadOriginalConfig(path)
|
||||||
|
backupPath := path + ".bak"
|
||||||
|
if hasOriginal {
|
||||||
|
_ = writeBackupConfig(backupPath, originalData)
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
|
||||||
|
state := &appState{
|
||||||
|
app: tview.NewApplication(),
|
||||||
|
pages: tview.NewPages(),
|
||||||
|
config: cfg,
|
||||||
|
configPath: path,
|
||||||
|
menus: map[string]*Menu{},
|
||||||
|
original: originalData,
|
||||||
|
hasOriginal: hasOriginal,
|
||||||
|
backupPath: backupPath,
|
||||||
|
logPath: logPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.push("main", state.mainMenu())
|
||||||
|
|
||||||
|
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
|
root.AddItem(bannerView(), 6, 0, false)
|
||||||
|
root.AddItem(state.pages, 0, 1, true)
|
||||||
|
|
||||||
|
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) push(name string, primitive tview.Primitive) {
|
||||||
|
s.pages.AddPage(name, primitive, true, true)
|
||||||
|
s.stack = append(s.stack, name)
|
||||||
|
s.pages.SwitchToPage(name)
|
||||||
|
if menu, ok := primitive.(*Menu); ok {
|
||||||
|
s.menus[name] = menu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) pop() {
|
||||||
|
if len(s.stack) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last := s.stack[len(s.stack)-1]
|
||||||
|
s.pages.RemovePage(last)
|
||||||
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
|
if len(s.stack) == 0 {
|
||||||
|
s.app.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := s.stack[len(s.stack)-1]
|
||||||
|
s.pages.SwitchToPage(current)
|
||||||
|
if menu, ok := s.menus[current]; ok {
|
||||||
|
s.refreshMenu(current, menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) mainMenu() tview.Primitive {
|
||||||
|
menu := NewMenu("Config Menu", nil)
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyEsc:
|
||||||
|
s.requestExit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.requestExit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) refreshMenu(name string, menu *Menu) {
|
||||||
|
switch name {
|
||||||
|
case "main":
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
case "model":
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
case "channel":
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMainMenuIfPresent(s *appState) {
|
||||||
|
if menu, ok := s.menus["main"]; ok {
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
|
selectedModel := s.selectedModelName()
|
||||||
|
modelReady := selectedModel != ""
|
||||||
|
channelReady := s.hasEnabledChannel()
|
||||||
|
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||||
|
|
||||||
|
gatewayLabel := "Start Gateway"
|
||||||
|
gatewayDescription := "Launch gateway for channels"
|
||||||
|
if gatewayRunning {
|
||||||
|
gatewayLabel = "Stop Gateway"
|
||||||
|
gatewayDescription = "Gateway running"
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []MenuItem{
|
||||||
|
{
|
||||||
|
Label: rootModelLabel(selectedModel),
|
||||||
|
Description: rootModelDescription(selectedModel),
|
||||||
|
Action: func() {
|
||||||
|
s.push("model", s.modelMenu())
|
||||||
|
},
|
||||||
|
MainColor: func() *tcell.Color {
|
||||||
|
if modelReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: rootChannelLabel(channelReady),
|
||||||
|
Description: rootChannelDescription(channelReady),
|
||||||
|
Action: func() {
|
||||||
|
s.push("channel", s.channelMenu())
|
||||||
|
},
|
||||||
|
MainColor: func() *tcell.Color {
|
||||||
|
if channelReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Start Talk",
|
||||||
|
Description: "Open picoclaw agent in terminal",
|
||||||
|
Action: func() {
|
||||||
|
s.requestStartTalk()
|
||||||
|
},
|
||||||
|
Disabled: !modelReady,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: gatewayLabel,
|
||||||
|
Description: gatewayDescription,
|
||||||
|
Action: func() {
|
||||||
|
if gatewayRunning {
|
||||||
|
s.stopGateway()
|
||||||
|
} else {
|
||||||
|
s.requestStartGateway()
|
||||||
|
}
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
},
|
||||||
|
Disabled: !gatewayRunning && (!modelReady || !channelReady),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "View Gateway Log",
|
||||||
|
Description: "Open gateway.log",
|
||||||
|
Action: func() {
|
||||||
|
s.viewGatewayLog()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Exit",
|
||||||
|
Description: "Exit the TUI",
|
||||||
|
Action: func() {
|
||||||
|
s.requestExit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) applyChangesValidated() bool {
|
||||||
|
if err := s.config.ValidateModelList(); err != nil {
|
||||||
|
s.showMessage("Validation failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := s.validateAgentModel(); err != nil {
|
||||||
|
s.showMessage("Validation failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := configstore.Save(s.config); err != nil {
|
||||||
|
s.showMessage("Save failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(s.configPath); err == nil {
|
||||||
|
s.original = data
|
||||||
|
s.hasOriginal = true
|
||||||
|
_ = writeBackupConfig(s.backupPath, data)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestExit() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.app.Stop()
|
||||||
|
}, func() {
|
||||||
|
s.discardChanges()
|
||||||
|
s.app.Stop()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.app.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestStartTalk() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.startTalk()
|
||||||
|
}, func() {
|
||||||
|
s.startTalk()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.startTalk()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestStartGateway() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.startGateway()
|
||||||
|
}, func() {
|
||||||
|
s.startGateway()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.startGateway()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) viewGatewayLog() {
|
||||||
|
data, err := os.ReadFile(s.logPath)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Log not found", "gateway.log not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetBorder(true).SetTitle("Gateway Log")
|
||||||
|
text.SetText(string(data))
|
||||||
|
text.SetDoneFunc(func(key tcell.Key) {
|
||||||
|
s.pages.RemovePage("log")
|
||||||
|
})
|
||||||
|
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pages.RemovePage("log")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
s.pages.AddPage("log", text, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) selectedModelName() string {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelLabel(selected string) string {
|
||||||
|
if selected == "" {
|
||||||
|
return "Model (no model selected)"
|
||||||
|
}
|
||||||
|
return "Model (" + selected + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelDescription(selected string) string {
|
||||||
|
if selected == "" {
|
||||||
|
return "no model selected"
|
||||||
|
}
|
||||||
|
return "selected"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelLabel(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "Channel (no channel enabled)"
|
||||||
|
}
|
||||||
|
return "Channel"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelDescription(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "no channel enabled"
|
||||||
|
}
|
||||||
|
return "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) startTalk() {
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.applyChangesValidated() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.app.Suspend(func() {
|
||||||
|
cmd := exec.Command("picoclaw", "agent")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
_ = cmd.Run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) startGateway() {
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
s.showMessage("Model required", "Select a valid model before starting gateway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.hasEnabledChannel() {
|
||||||
|
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.applyChangesValidated() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = stopGatewayProcess()
|
||||||
|
cmd := exec.Command("picoclaw", "gateway")
|
||||||
|
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Gateway failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.Stdout = logFile
|
||||||
|
cmd.Stderr = logFile
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
s.showMessage("Gateway failed", err.Error())
|
||||||
|
_ = logFile.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = logFile.Close()
|
||||||
|
s.gatewayCmd = cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) stopGateway() {
|
||||||
|
_ = stopGatewayProcess()
|
||||||
|
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
|
||||||
|
_ = s.gatewayCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
s.gatewayCmd = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) isGatewayRunning() bool {
|
||||||
|
return isGatewayProcessRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) validateAgentModel() error {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.config.GetModelConfig(modelName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) isActiveModelValid() bool {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cfg, err := s.config.GetModelConfig(modelName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
||||||
|
hasModel := strings.TrimSpace(cfg.Model) != ""
|
||||||
|
return hasKey && hasModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) hasEnabledChannel() bool {
|
||||||
|
c := s.config.Channels
|
||||||
|
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
||||||
|
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||||
|
c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
||||||
|
if s.pages.HasPage("apply") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText("Apply changes or discard before continuing?").
|
||||||
|
AddButtons([]string{"Cancel", "Discard", "Apply"}).
|
||||||
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
s.pages.RemovePage("apply")
|
||||||
|
switch buttonLabel {
|
||||||
|
case "Discard":
|
||||||
|
s.discardChanges()
|
||||||
|
if onDiscard != nil {
|
||||||
|
onDiscard()
|
||||||
|
}
|
||||||
|
case "Apply":
|
||||||
|
if s.applyChangesValidated() {
|
||||||
|
s.dirty = false
|
||||||
|
if onApply != nil {
|
||||||
|
onApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
modal.SetBorder(true)
|
||||||
|
s.pages.AddPage("apply", modal, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) discardChanges() {
|
||||||
|
if s.hasOriginal {
|
||||||
|
_ = writeOriginalConfig(s.configPath, s.original)
|
||||||
|
} else {
|
||||||
|
_ = os.Remove(s.configPath)
|
||||||
|
}
|
||||||
|
_ = os.Remove(s.backupPath)
|
||||||
|
if cfg, err := configstore.Load(); err == nil && cfg != nil {
|
||||||
|
s.config = cfg
|
||||||
|
}
|
||||||
|
s.dirty = false
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) showMessage(title, message string) {
|
||||||
|
if s.pages.HasPage("message") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(strings.TrimSpace(message)).
|
||||||
|
AddButtons([]string{"OK"}).
|
||||||
|
SetDoneFunc(func(_ int, _ string) {
|
||||||
|
s.pages.RemovePage("message")
|
||||||
|
})
|
||||||
|
modal.SetTitle(title).SetBorder(true)
|
||||||
|
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
|
||||||
|
modal.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
|
||||||
|
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
s.pages.AddPage("message", modal, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOriginalConfig(path string) ([]byte, bool) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOriginalConfig(path string, data []byte) error {
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBackupConfig(path string, data []byte) error {
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) channelMenu() tview.Primitive {
|
||||||
|
items := []MenuItem{
|
||||||
|
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
channelItem(
|
||||||
|
"Telegram",
|
||||||
|
"Telegram bot settings",
|
||||||
|
s.config.Channels.Telegram.Enabled,
|
||||||
|
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Discord",
|
||||||
|
"Discord bot settings",
|
||||||
|
s.config.Channels.Discord.Enabled,
|
||||||
|
func() { s.push("channel-discord", s.discordForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"QQ",
|
||||||
|
"QQ bot settings",
|
||||||
|
s.config.Channels.QQ.Enabled,
|
||||||
|
func() { s.push("channel-qq", s.qqForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"MaixCam",
|
||||||
|
"MaixCam gateway",
|
||||||
|
s.config.Channels.MaixCam.Enabled,
|
||||||
|
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WhatsApp",
|
||||||
|
"WhatsApp bridge",
|
||||||
|
s.config.Channels.WhatsApp.Enabled,
|
||||||
|
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Feishu",
|
||||||
|
"Feishu bot settings",
|
||||||
|
s.config.Channels.Feishu.Enabled,
|
||||||
|
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"DingTalk",
|
||||||
|
"DingTalk bot settings",
|
||||||
|
s.config.Channels.DingTalk.Enabled,
|
||||||
|
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Slack",
|
||||||
|
"Slack bot settings",
|
||||||
|
s.config.Channels.Slack.Enabled,
|
||||||
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"LINE",
|
||||||
|
"LINE bot settings",
|
||||||
|
s.config.Channels.LINE.Enabled,
|
||||||
|
func() { s.push("channel-line", s.lineForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"OneBot",
|
||||||
|
"OneBot settings",
|
||||||
|
s.config.Channels.OneBot.Enabled,
|
||||||
|
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom",
|
||||||
|
"WeCom bot settings",
|
||||||
|
s.config.Channels.WeCom.Enabled,
|
||||||
|
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom App",
|
||||||
|
"WeCom App settings",
|
||||||
|
s.config.Channels.WeComApp.Enabled,
|
||||||
|
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := NewMenu("Channels", items)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||||
|
items := []MenuItem{
|
||||||
|
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
channelItem(
|
||||||
|
"Telegram",
|
||||||
|
"Telegram bot settings",
|
||||||
|
s.config.Channels.Telegram.Enabled,
|
||||||
|
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Discord",
|
||||||
|
"Discord bot settings",
|
||||||
|
s.config.Channels.Discord.Enabled,
|
||||||
|
func() { s.push("channel-discord", s.discordForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"QQ",
|
||||||
|
"QQ bot settings",
|
||||||
|
s.config.Channels.QQ.Enabled,
|
||||||
|
func() { s.push("channel-qq", s.qqForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"MaixCam",
|
||||||
|
"MaixCam gateway",
|
||||||
|
s.config.Channels.MaixCam.Enabled,
|
||||||
|
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WhatsApp",
|
||||||
|
"WhatsApp bridge",
|
||||||
|
s.config.Channels.WhatsApp.Enabled,
|
||||||
|
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Feishu",
|
||||||
|
"Feishu bot settings",
|
||||||
|
s.config.Channels.Feishu.Enabled,
|
||||||
|
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"DingTalk",
|
||||||
|
"DingTalk bot settings",
|
||||||
|
s.config.Channels.DingTalk.Enabled,
|
||||||
|
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Slack",
|
||||||
|
"Slack bot settings",
|
||||||
|
s.config.Channels.Slack.Enabled,
|
||||||
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"LINE",
|
||||||
|
"LINE bot settings",
|
||||||
|
s.config.Channels.LINE.Enabled,
|
||||||
|
func() { s.push("channel-line", s.lineForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"OneBot",
|
||||||
|
"OneBot settings",
|
||||||
|
s.config.Channels.OneBot.Enabled,
|
||||||
|
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom",
|
||||||
|
"WeCom bot settings",
|
||||||
|
s.config.Channels.WeCom.Enabled,
|
||||||
|
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom App",
|
||||||
|
"WeCom App settings",
|
||||||
|
s.config.Channels.WeComApp.Enabled,
|
||||||
|
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) telegramForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Telegram
|
||||||
|
form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
||||||
|
cfg.Proxy = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) discordForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Discord
|
||||||
|
form := baseChannelForm("Discord", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
||||||
|
cfg.MentionOnly = checked
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) qqForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.QQ
|
||||||
|
form := baseChannelForm("QQ", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||||
|
cfg.AppID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||||
|
cfg.AppSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) maixcamForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.MaixCam
|
||||||
|
form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
|
||||||
|
cfg.Host = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) whatsappForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WhatsApp
|
||||||
|
form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
||||||
|
cfg.BridgeURL = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) feishuForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Feishu
|
||||||
|
form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||||
|
cfg.AppID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||||
|
cfg.AppSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncryptKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
||||||
|
cfg.VerificationToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) dingtalkForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.DingTalk
|
||||||
|
form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
||||||
|
cfg.ClientID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
|
||||||
|
cfg.ClientSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) slackForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Slack
|
||||||
|
form := baseChannelForm("Slack", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
|
||||||
|
cfg.BotToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
|
||||||
|
cfg.AppToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) lineForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.LINE
|
||||||
|
form := baseChannelForm("LINE", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
|
||||||
|
cfg.ChannelSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.ChannelAccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) onebotForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.OneBot
|
||||||
|
form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
||||||
|
cfg.WSUrl = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.AccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reconnect Interval",
|
||||||
|
cfg.ReconnectInterval,
|
||||||
|
func(value int) { cfg.ReconnectInterval = value },
|
||||||
|
)
|
||||||
|
form.AddInputField(
|
||||||
|
"Group Trigger Prefix",
|
||||||
|
strings.Join(cfg.GroupTriggerPrefix, ","),
|
||||||
|
128,
|
||||||
|
nil,
|
||||||
|
func(text string) {
|
||||||
|
cfg.GroupTriggerPrefix = splitCSV(text)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeCom
|
||||||
|
form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
||||||
|
cfg.WebhookURL = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomAppForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeComApp
|
||||||
|
form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
||||||
|
cfg.CorpID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
||||||
|
cfg.CorpSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||||
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||||
|
form.AddCheckbox("Enabled", enabled, func(checked bool) {
|
||||||
|
onEnabled(checked)
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
|
||||||
|
form.AddButton("Back", func() {
|
||||||
|
s.pop()
|
||||||
|
})
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
|
||||||
|
parts := strings.Split(strings.TrimSpace(input), ",")
|
||||||
|
cleaned := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
value := strings.TrimSpace(part)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, value)
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int64
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
|
||||||
|
item := MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: description,
|
||||||
|
Action: action,
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
color := tcell.ColorGray
|
||||||
|
item.MainColor = &color
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func isGatewayProcessRunning() bool {
|
||||||
|
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGatewayProcess() error {
|
||||||
|
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func isGatewayProcessRunning() bool {
|
||||||
|
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGatewayProcess() error {
|
||||||
|
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MenuAction func()
|
||||||
|
|
||||||
|
type MenuItem struct {
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Action MenuAction
|
||||||
|
Disabled bool
|
||||||
|
MainColor *tcell.Color
|
||||||
|
DescColor *tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
type Menu struct {
|
||||||
|
*tview.Table
|
||||||
|
items []MenuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMenu(title string, items []MenuItem) *Menu {
|
||||||
|
table := tview.NewTable().SetSelectable(true, false)
|
||||||
|
table.SetBorder(true).SetTitle(title)
|
||||||
|
table.SetBorders(false)
|
||||||
|
menu := &Menu{Table: table, items: items}
|
||||||
|
menu.applyItems(items)
|
||||||
|
menu.SetSelectedFunc(func(row, _ int) {
|
||||||
|
if row < 0 || row >= len(menu.items) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := menu.items[row]
|
||||||
|
if item.Disabled || item.Action == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.Action()
|
||||||
|
})
|
||||||
|
menu.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
|
||||||
|
Background(tcell.NewRGBColor(189, 147, 249)),
|
||||||
|
)
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Menu) applyItems(items []MenuItem) {
|
||||||
|
m.items = items
|
||||||
|
m.Clear()
|
||||||
|
for row, item := range items {
|
||||||
|
label := item.Label
|
||||||
|
if item.Disabled && label != "" {
|
||||||
|
label = label + " (disabled)"
|
||||||
|
}
|
||||||
|
left := tview.NewTableCell(label)
|
||||||
|
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
|
||||||
|
if item.MainColor != nil {
|
||||||
|
left.SetTextColor(*item.MainColor)
|
||||||
|
}
|
||||||
|
if item.DescColor != nil {
|
||||||
|
right.SetTextColor(*item.DescColor)
|
||||||
|
} else {
|
||||||
|
right.SetTextColor(tview.Styles.TertiaryTextColor)
|
||||||
|
}
|
||||||
|
if item.Disabled {
|
||||||
|
left.SetTextColor(tcell.ColorGray)
|
||||||
|
right.SetTextColor(tcell.ColorGray)
|
||||||
|
}
|
||||||
|
m.SetCell(row, 0, left)
|
||||||
|
m.SetCell(row, 1, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) modelMenu() tview.Primitive {
|
||||||
|
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
MenuItem{
|
||||||
|
Label: "Add model",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
index := i
|
||||||
|
model := s.config.ModelList[i]
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
desc := model.APIBase
|
||||||
|
if desc == "" {
|
||||||
|
desc = model.AuthMethod
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = "api_key required"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
items = append(items, MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MainColor: modelStatusColor(isValid, isSelected),
|
||||||
|
Action: func() {
|
||||||
|
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := NewMenu("Models", items)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == ' ' {
|
||||||
|
row, _ := menu.GetSelection()
|
||||||
|
if row > 0 && row <= len(s.config.ModelList) {
|
||||||
|
model := s.config.ModelList[row-1]
|
||||||
|
if !isModelValid(model) {
|
||||||
|
s.showMessage(
|
||||||
|
"Invalid model",
|
||||||
|
"Select a model with api_key or oauth auth_method",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.config.Agents.Defaults.Model = model.ModelName
|
||||||
|
s.dirty = true
|
||||||
|
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
|
model := &s.config.ModelList[index]
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||||
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||||
|
|
||||||
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||||
|
model.ModelName = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Model", model.Model, func(value string) {
|
||||||
|
model.Model = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "API Base", model.APIBase, func(value string) {
|
||||||
|
model.APIBase = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "API Key", model.APIKey, func(value string) {
|
||||||
|
model.APIKey = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Proxy", model.Proxy, func(value string) {
|
||||||
|
model.Proxy = value
|
||||||
|
})
|
||||||
|
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
|
||||||
|
model.AuthMethod = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
|
||||||
|
model.ConnectMode = value
|
||||||
|
})
|
||||||
|
addInput(form, "Workspace", model.Workspace, func(value string) {
|
||||||
|
model.Workspace = value
|
||||||
|
})
|
||||||
|
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
|
||||||
|
model.MaxTokensField = value
|
||||||
|
})
|
||||||
|
addIntInput(form, "RPM", model.RPM, func(value int) {
|
||||||
|
model.RPM = value
|
||||||
|
})
|
||||||
|
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
|
||||||
|
model.RequestTimeout = value
|
||||||
|
})
|
||||||
|
|
||||||
|
form.AddButton("Delete", func() {
|
||||||
|
s.deleteModel(index)
|
||||||
|
})
|
||||||
|
form.AddButton("Test", func() {
|
||||||
|
s.testModel(model)
|
||||||
|
})
|
||||||
|
form.AddButton("Back", func() {
|
||||||
|
s.pop()
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInput(form *tview.Form, label, value string, onChange func(string)) {
|
||||||
|
form.AddInputField(label, value, 128, nil, func(text string) {
|
||||||
|
onChange(strings.TrimSpace(text))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
||||||
|
s.config.ModelList = append(s.config.ModelList, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) deleteModel(index int) {
|
||||||
|
if index < 0 || index >= len(s.config.ModelList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
|
||||||
|
s.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||||
|
if valid {
|
||||||
|
color := tview.Styles.PrimaryTextColor
|
||||||
|
return &color
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||||
|
for i, model := range models {
|
||||||
|
row := i + 1
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
cell := menu.GetCell(row, 0)
|
||||||
|
if cell != nil {
|
||||||
|
cell.SetText(label)
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
color := modelStatusColor(isValid, isSelected)
|
||||||
|
if color != nil {
|
||||||
|
cell.SetTextColor(*color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
|
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
MenuItem{
|
||||||
|
Label: "Add model",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
index := i
|
||||||
|
model := s.config.ModelList[i]
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
desc := model.APIBase
|
||||||
|
if desc == "" {
|
||||||
|
desc = model.AuthMethod
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = "api_key required"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
items = append(items, MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MainColor: modelStatusColor(isValid, isSelected),
|
||||||
|
Action: func() {
|
||||||
|
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||||
|
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
||||||
|
strings.TrimSpace(model.AuthMethod) == "oauth"
|
||||||
|
hasModel := strings.TrimSpace(model.Model) != ""
|
||||||
|
return hasKey && hasModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
|
if model == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(model.APIKey) == "" {
|
||||||
|
s.showMessage("Missing API Key", "Set api_key before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(model.APIBase)
|
||||||
|
if base == "" {
|
||||||
|
s.showMessage("Missing API Base", "Set api_base before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelID := strings.TrimSpace(model.Model)
|
||||||
|
if modelID == "" {
|
||||||
|
s.showMessage("Missing Model", "Set model before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(modelID, "openai/") {
|
||||||
|
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelName := strings.TrimPrefix(modelID, "openai/")
|
||||||
|
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
payload := fmt.Sprintf(
|
||||||
|
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
|
||||||
|
modelName,
|
||||||
|
)
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
||||||
|
|
||||||
|
resp, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
s.showMessage("Test OK", resp.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
s.showMessage(
|
||||||
|
"Test failed",
|
||||||
|
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyStyles() {
|
||||||
|
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
|
||||||
|
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
|
||||||
|
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
|
||||||
|
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
|
||||||
|
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
|
||||||
|
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
|
||||||
|
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
|
||||||
|
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
|
||||||
|
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
|
||||||
|
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
|
||||||
|
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bannerView() *tview.TextView {
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetDynamicColors(true)
|
||||||
|
text.SetTextAlign(tview.AlignCenter)
|
||||||
|
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
||||||
|
text.SetText(
|
||||||
|
"[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||||
|
"[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||||
|
"[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||||
|
"[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||||
|
"[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||||
|
"[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
|
||||||
|
)
|
||||||
|
text.SetBorder(false)
|
||||||
|
return text
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := ui.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# PicoClaw Launcher
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
|
||||||
|
|
||||||
|
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||||
|
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||||
|
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||||
|
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||||
|
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||||
|
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||||
|
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
|
||||||
|
# Run with default config path (~/.picoclaw/config.json)
|
||||||
|
./picoclaw-launcher
|
||||||
|
|
||||||
|
# Specify a config file
|
||||||
|
./picoclaw-launcher ./config.json
|
||||||
|
|
||||||
|
# Allow LAN access
|
||||||
|
./picoclaw-launcher -public
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:18800` in your browser.
|
||||||
|
|
||||||
|
## CLI Options
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: picoclaw-config [options] [config.json]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
Base URL: `http://localhost:18800`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Static Files
|
||||||
|
|
||||||
|
#### GET /
|
||||||
|
|
||||||
|
Serves the embedded frontend (`index.html`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Config API
|
||||||
|
|
||||||
|
#### GET /api/config
|
||||||
|
|
||||||
|
Reads the current configuration file.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { ... },
|
||||||
|
"path": "/Users/xiao/.picoclaw/config.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/config
|
||||||
|
|
||||||
|
Saves the configuration. The request body must be a complete Config JSON object.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error** `400 Bad Request` — Invalid JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auth API
|
||||||
|
|
||||||
|
#### GET /api/auth/status
|
||||||
|
|
||||||
|
Returns the authentication status of all providers and any in-progress device code login.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"provider": "openai",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"status": "active",
|
||||||
|
"account_id": "user-xxx",
|
||||||
|
"expires_at": "2026-03-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_device": {
|
||||||
|
"provider": "openai",
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` values: `active` | `expired` | `needs_refresh`
|
||||||
|
|
||||||
|
`pending_device` is only present when a device code login is in progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/login
|
||||||
|
|
||||||
|
Initiates a provider login.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
|
||||||
|
|
||||||
|
##### OpenAI (Device Code Flow)
|
||||||
|
|
||||||
|
Returns device code info. The server polls for completion in the background.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234",
|
||||||
|
"message": "Open the URL and enter the code to authenticate."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
|
||||||
|
|
||||||
|
##### Anthropic (API Token)
|
||||||
|
|
||||||
|
Requires a `token` field in the request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "success", "message": "Anthropic token saved" }
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Google Antigravity (Browser OAuth)
|
||||||
|
|
||||||
|
Returns an authorization URL for the frontend to open in a new tab:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||||
|
"message": "Open the URL to authenticate with Google."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/logout
|
||||||
|
|
||||||
|
Logs out from a provider.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit or leave `provider` empty to log out from all providers.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /auth/callback
|
||||||
|
|
||||||
|
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `state` — OAuth state for CSRF validation
|
||||||
|
- `code` — Authorization code
|
||||||
|
|
||||||
|
On success, redirects to `/#auth`.
|
||||||
|
|
||||||
|
|
||||||
|
### Process API
|
||||||
|
|
||||||
|
#### GET /api/process/status
|
||||||
|
|
||||||
|
Gets the running status of the `picoclaw gateway` process.
|
||||||
|
|
||||||
|
**Response** `200 OK` (Running)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "running",
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": "1.010814s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK` (Stopped)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "stopped",
|
||||||
|
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/start
|
||||||
|
|
||||||
|
Starts the `picoclaw gateway` process in the background.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/stop
|
||||||
|
|
||||||
|
Stops the running `picoclaw gateway` process.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v ./cmd/picoclaw-launcher/
|
||||||
|
```
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# PicoClaw Launcher
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
|
||||||
|
|
||||||
|
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器
|
||||||
|
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
|
||||||
|
- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
|
||||||
|
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
|
||||||
|
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
|
||||||
|
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
|
||||||
|
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译
|
||||||
|
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
|
||||||
|
# 运行(使用默认配置路径 ~/.picoclaw/config.json)
|
||||||
|
./picoclaw-launcher
|
||||||
|
|
||||||
|
# 指定配置文件
|
||||||
|
./picoclaw-launcher ./config.json
|
||||||
|
|
||||||
|
# 允许局域网访问
|
||||||
|
./picoclaw-launcher -public
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后在浏览器中打开 `http://localhost:18800`。
|
||||||
|
|
||||||
|
## 命令行参数
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: picoclaw-launcher [options] [config.json]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
config.json 配置文件路径(默认: ~/.picoclaw/config.json)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-public 监听所有网络接口(0.0.0.0),允许局域网设备访问
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
Base URL: `http://localhost:18800`
|
||||||
|
|
||||||
|
### 静态文件
|
||||||
|
|
||||||
|
#### GET /
|
||||||
|
|
||||||
|
提供嵌入式前端页面(`index.html`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Config API
|
||||||
|
|
||||||
|
#### GET /api/config
|
||||||
|
|
||||||
|
读取当前配置文件内容。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { ... },
|
||||||
|
"path": "/Users/xiao/.picoclaw/config.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/config
|
||||||
|
|
||||||
|
保存配置。请求体为完整的 Config JSON。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error** `400 Bad Request` — 无效 JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auth API
|
||||||
|
|
||||||
|
#### GET /api/auth/status
|
||||||
|
|
||||||
|
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"provider": "openai",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"status": "active",
|
||||||
|
"account_id": "user-xxx",
|
||||||
|
"expires_at": "2026-03-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_device": {
|
||||||
|
"provider": "openai",
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` 可选值: `active` | `expired` | `needs_refresh`
|
||||||
|
|
||||||
|
`pending_device` 仅在有进行中的 Device Code 登录时返回。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/login
|
||||||
|
|
||||||
|
发起 Provider 登录。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
|
||||||
|
|
||||||
|
##### OpenAI (Device Code Flow)
|
||||||
|
|
||||||
|
返回 Device Code 信息,后台自动轮询认证结果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234",
|
||||||
|
"message": "Open the URL and enter the code to authenticate."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。
|
||||||
|
|
||||||
|
##### Anthropic (API Token)
|
||||||
|
|
||||||
|
需在请求中附带 token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "success", "message": "Anthropic token saved" }
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Google Antigravity (Browser OAuth)
|
||||||
|
|
||||||
|
返回授权 URL,前端打开新标签页:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||||
|
"message": "Open the URL to authenticate with Google."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/logout
|
||||||
|
|
||||||
|
登出 Provider。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
传空字符串或省略 `provider` 则登出所有 Provider。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /auth/callback
|
||||||
|
|
||||||
|
OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `state` — OAuth state 校验
|
||||||
|
- `code` — 授权码
|
||||||
|
|
||||||
|
认证成功后重定向到 `/#auth`。
|
||||||
|
|
||||||
|
### Process API
|
||||||
|
|
||||||
|
#### GET /api/process/status
|
||||||
|
|
||||||
|
获取 `picoclaw gateway` 进程的运行状态。
|
||||||
|
|
||||||
|
**Response** `200 OK` (运行中)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "running",
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": "1.010814s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK` (未运行)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "stopped",
|
||||||
|
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/start
|
||||||
|
|
||||||
|
在后台启动 `picoclaw gateway` 进程。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/stop
|
||||||
|
|
||||||
|
停止正在运行的 `picoclaw gateway` 进程。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v ./cmd/picoclaw-launcher/
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,147 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateConfigAfterLogin updates config.json after a successful provider login.
|
||||||
|
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: could not load config to update auth_method: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gpt-5.2",
|
||||||
|
Model: "openai/gpt-5.2",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||||
|
|
||||||
|
case "anthropic":
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = "token"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "token"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "claude-sonnet-4.6",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
AuthMethod: "token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||||
|
|
||||||
|
case "google-antigravity":
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gemini-flash",
|
||||||
|
Model: "antigravity/gemini-3-flash",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
log.Printf("Warning: could not update config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
|
||||||
|
func clearAuthMethodInConfig(configPath, provider string) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
case "anthropic":
|
||||||
|
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = ""
|
||||||
|
case "anthropic":
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = ""
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
config.SaveConfig(configPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
|
||||||
|
func clearAllAuthMethodsInConfig(configPath string) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = ""
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = ""
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = ""
|
||||||
|
config.SaveConfig(configPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model identification helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
func isOpenAIModel(model string) bool {
|
||||||
|
return model == "openai" || strings.HasPrefix(model, "openai/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAnthropicModel(model string) bool {
|
||||||
|
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAntigravityModel(model string) bool {
|
||||||
|
return model == "antigravity" || model == "google-antigravity" ||
|
||||||
|
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Model identification helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
func TestIsOpenAIModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"openai", true},
|
||||||
|
{"openai/gpt-4o", true},
|
||||||
|
{"openai/gpt-5.2", true},
|
||||||
|
{"anthropic", false},
|
||||||
|
{"anthropic/claude-sonnet-4.6", false},
|
||||||
|
{"openai-compatible", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isOpenAIModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAnthropicModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"anthropic", true},
|
||||||
|
{"anthropic/claude-sonnet-4.6", true},
|
||||||
|
{"openai", false},
|
||||||
|
{"openai/gpt-4o", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isAnthropicModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAntigravityModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"antigravity", true},
|
||||||
|
{"google-antigravity", true},
|
||||||
|
{"antigravity/gemini-3-flash", true},
|
||||||
|
{"google-antigravity/gemini-3-flash", true},
|
||||||
|
{"openai", false},
|
||||||
|
{"antigravity-custom", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isAntigravityModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config update helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
if err := config.SaveConfig(path, cfg); err != nil {
|
||||||
|
t.Fatalf("save config: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTempConfig(t *testing.T, path string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := config.LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "openai", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model-level auth_method persists through serialization
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "oauth" {
|
||||||
|
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "openai", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
if len(result.ModelList) != 2 {
|
||||||
|
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[1].Model != "openai/gpt-5.2" {
|
||||||
|
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
|
||||||
|
}
|
||||||
|
if result.Agents.Defaults.ModelName != "gpt-5.2" {
|
||||||
|
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "token"}
|
||||||
|
updateConfigAfterLogin(path, "anthropic", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model should be added with correct auth_method
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||||
|
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "token" {
|
||||||
|
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "google-antigravity", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model should be added with correct auth_method
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
|
||||||
|
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "oauth" {
|
||||||
|
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearAuthMethodInConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
clearAuthMethodInConfig(path, "openai")
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Openai model auth_method should be cleared
|
||||||
|
if result.ModelList[0].AuthMethod != "" {
|
||||||
|
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
// Anthropic model should be unchanged
|
||||||
|
if result.ModelList[1].AuthMethod != "token" {
|
||||||
|
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearAllAuthMethodsInConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
clearAllAuthMethodsInConfig(path)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
for i, m := range result.ModelList {
|
||||||
|
if m.AuthMethod != "" {
|
||||||
|
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// oauthSession stores in-flight OAuth state for browser-based flows.
|
||||||
|
type oauthSession struct {
|
||||||
|
Provider string
|
||||||
|
PKCE auth.PKCECodes
|
||||||
|
State string
|
||||||
|
RedirectURI string
|
||||||
|
OAuthCfg auth.OAuthProviderConfig
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceCodeSession stores in-flight device code flow state.
|
||||||
|
type deviceCodeSession struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
Provider string
|
||||||
|
Info *auth.DeviceCodeInfo
|
||||||
|
OAuthCfg auth.OAuthProviderConfig
|
||||||
|
ConfigPath string
|
||||||
|
Status string // "pending", "success", "error"
|
||||||
|
Error string
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
oauthSessions = map[string]*oauthSession{} // keyed by state
|
||||||
|
oauthSessionsMu sync.Mutex
|
||||||
|
|
||||||
|
activeDeviceSession *deviceCodeSession
|
||||||
|
activeDeviceSessionMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
|
||||||
|
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
|
||||||
|
// Check if there's already a pending device code session
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
if activeDeviceSession != nil {
|
||||||
|
activeDeviceSession.mu.Lock()
|
||||||
|
if !activeDeviceSession.Done {
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||||
|
"user_code": activeDeviceSession.Info.UserCode,
|
||||||
|
"message": "Device code flow already in progress. Enter the code in your browser.",
|
||||||
|
}
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
}
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
// Request a device code
|
||||||
|
oauthCfg := auth.OpenAIOAuthConfig()
|
||||||
|
info, err := auth.RequestDeviceCode(oauthCfg)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &deviceCodeSession{
|
||||||
|
Provider: "openai",
|
||||||
|
Info: info,
|
||||||
|
OAuthCfg: oauthCfg,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
activeDeviceSession = session
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
// Start background polling
|
||||||
|
go func() {
|
||||||
|
deadline := time.After(15 * time.Minute)
|
||||||
|
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "error"
|
||||||
|
session.Error = "Authentication timed out after 15 minutes"
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
|
||||||
|
if err != nil {
|
||||||
|
continue // Still pending
|
||||||
|
}
|
||||||
|
if cred != nil {
|
||||||
|
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "error"
|
||||||
|
session.Error = saveErr.Error()
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateConfigAfterLogin(configPath, "openai", cred)
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "success"
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Return device code info to frontend
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": info.VerifyURL,
|
||||||
|
"user_code": info.UserCode,
|
||||||
|
"message": "Open the URL and enter the code to authenticate.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAnthropicLogin saves a pasted API token for Anthropic.
|
||||||
|
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{
|
||||||
|
AccessToken: token,
|
||||||
|
Provider: "anthropic",
|
||||||
|
AuthMethod: "token",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential("anthropic", cred); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigAfterLogin(configPath, "anthropic", cred)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Anthropic token saved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
|
||||||
|
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
|
||||||
|
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
|
pkce, err := auth.GeneratePKCE()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := auth.GenerateState()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build redirect URI pointing to picoclaw-launcher's own callback
|
||||||
|
scheme := "http"
|
||||||
|
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
|
||||||
|
|
||||||
|
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
|
||||||
|
|
||||||
|
// Store session for callback
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
oauthSessions[state] = &oauthSession{
|
||||||
|
Provider: "google-antigravity",
|
||||||
|
PKCE: pkce,
|
||||||
|
State: state,
|
||||||
|
RedirectURI: redirectURI,
|
||||||
|
OAuthCfg: oauthCfg,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
}
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
|
||||||
|
// Clean up stale sessions after 10 minutes
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Minute)
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
delete(oauthSessions, state)
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": authURL,
|
||||||
|
"message": "Open the URL to authenticate with Google.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
|
||||||
|
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
session, ok := oauthSessions[state]
|
||||||
|
if ok {
|
||||||
|
delete(oauthSessions, state)
|
||||||
|
}
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
errMsg := r.URL.Query().Get("error")
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||||
|
errMsg,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred.Provider = session.Provider
|
||||||
|
|
||||||
|
// Fetch user info for Google Antigravity
|
||||||
|
if session.Provider == "google-antigravity" {
|
||||||
|
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
|
||||||
|
cred.Email = email
|
||||||
|
}
|
||||||
|
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
|
||||||
|
cred.ProjectID = projectID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential(session.Provider, cred); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
|
||||||
|
|
||||||
|
// Redirect back to picoclaw-launcher UI
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(w, `<html><body>
|
||||||
|
<h2>Authentication successful!</h2>
|
||||||
|
<p>Redirecting back to Config Editor...</p>
|
||||||
|
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
|
||||||
|
</body></html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
|
||||||
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
|
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return userInfo.Email, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
|
||||||
|
// It supports incremental reads via LinesSince and tracks a runID that increments
|
||||||
|
// on each Reset (used to detect gateway restarts).
|
||||||
|
type LogBuffer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lines []string
|
||||||
|
cap int
|
||||||
|
total int // total lines ever appended in current run
|
||||||
|
runID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogBuffer creates a LogBuffer with the given capacity.
|
||||||
|
func NewLogBuffer(capacity int) *LogBuffer {
|
||||||
|
return &LogBuffer{
|
||||||
|
lines: make([]string, 0, capacity),
|
||||||
|
cap: capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted.
|
||||||
|
func (b *LogBuffer) Append(line string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(b.lines) < b.cap {
|
||||||
|
b.lines = append(b.lines, line)
|
||||||
|
} else {
|
||||||
|
b.lines[b.total%b.cap] = line
|
||||||
|
}
|
||||||
|
|
||||||
|
b.total++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the buffer and increments the runID. Call this when starting a new gateway process.
|
||||||
|
func (b *LogBuffer) Reset() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.lines = b.lines[:0]
|
||||||
|
b.total = 0
|
||||||
|
b.runID++
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinesSince returns lines appended after the given offset, the current total count, and the runID.
|
||||||
|
// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.
|
||||||
|
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
total = b.total
|
||||||
|
runID = b.runID
|
||||||
|
|
||||||
|
if offset >= b.total {
|
||||||
|
return nil, total, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
buffered := len(b.lines)
|
||||||
|
|
||||||
|
// How many new lines since offset
|
||||||
|
newCount := b.total - offset
|
||||||
|
if newCount > buffered {
|
||||||
|
newCount = buffered
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, newCount)
|
||||||
|
|
||||||
|
if b.total <= b.cap {
|
||||||
|
// Buffer hasn't wrapped yet — simple slice
|
||||||
|
copy(result, b.lines[buffered-newCount:])
|
||||||
|
} else {
|
||||||
|
// Buffer has wrapped — read from ring
|
||||||
|
start := (b.total - newCount) % b.cap
|
||||||
|
for i := range newCount {
|
||||||
|
result[i] = b.lines[(start+i)%b.cap]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, total, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunID returns the current run identifier.
|
||||||
|
func (b *LogBuffer) RunID() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
return b.runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total returns the total number of lines appended in the current run.
|
||||||
|
func (b *LogBuffer) Total() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
return b.total
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogBuffer_Basic(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(5)
|
||||||
|
|
||||||
|
// Empty buffer
|
||||||
|
lines, total, runID := buf.LinesSince(0)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.Equal(t, 0, runID)
|
||||||
|
|
||||||
|
// Append some lines
|
||||||
|
buf.Append("line1")
|
||||||
|
buf.Append("line2")
|
||||||
|
buf.Append("line3")
|
||||||
|
|
||||||
|
lines, total, runID = buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
assert.Equal(t, 0, runID)
|
||||||
|
|
||||||
|
// Incremental read
|
||||||
|
lines, total, _ = buf.LinesSince(2)
|
||||||
|
assert.Equal(t, []string{"line3"}, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
|
||||||
|
// No new lines
|
||||||
|
lines, total, _ = buf.LinesSince(3)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Wrap(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(3)
|
||||||
|
|
||||||
|
buf.Append("a")
|
||||||
|
buf.Append("b")
|
||||||
|
buf.Append("c")
|
||||||
|
buf.Append("d") // evicts "a"
|
||||||
|
buf.Append("e") // evicts "b"
|
||||||
|
|
||||||
|
lines, total, _ := buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
|
||||||
|
// Incremental after wrap
|
||||||
|
lines, total, _ = buf.LinesSince(3)
|
||||||
|
assert.Equal(t, []string{"d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
|
||||||
|
// Offset too old (before buffer start), get all buffered
|
||||||
|
lines, total, _ = buf.LinesSince(1)
|
||||||
|
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Reset(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(5)
|
||||||
|
|
||||||
|
buf.Append("before")
|
||||||
|
assert.Equal(t, 0, buf.RunID())
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
assert.Equal(t, 1, buf.RunID())
|
||||||
|
assert.Equal(t, 0, buf.Total())
|
||||||
|
|
||||||
|
lines, total, runID := buf.LinesSince(0)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.Equal(t, 1, runID)
|
||||||
|
|
||||||
|
buf.Append("after")
|
||||||
|
lines, total, runID = buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"after"}, lines)
|
||||||
|
assert.Equal(t, 1, total)
|
||||||
|
assert.Equal(t, 1, runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Concurrent(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(100)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// 10 writers
|
||||||
|
for i := range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range 50 {
|
||||||
|
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 readers
|
||||||
|
for range 5 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for range 100 {
|
||||||
|
buf.LinesSince(0)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, 500, buf.Total())
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
|
||||||
|
var gatewayLogs = NewLogBuffer(200)
|
||||||
|
|
||||||
|
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
|
||||||
|
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleStatusGateway(w, r, absPath)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/process/start", handleStartGateway)
|
||||||
|
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Locate picoclaw executable:
|
||||||
|
// 1. Try same directory as current executable
|
||||||
|
// 2. Fallback to just "picoclaw" (relies on $PATH)
|
||||||
|
execPath := "picoclaw"
|
||||||
|
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
candidate := filepath.Join(dir, "picoclaw")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
candidate += ".exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
execPath = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(execPath, "gateway")
|
||||||
|
|
||||||
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create stdout pipe: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create stderr pipe: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old logs and increment runID before starting
|
||||||
|
gatewayLogs.Reset()
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Printf("Failed to start picoclaw gateway: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read stdout and stderr into the log buffer
|
||||||
|
go scanPipe(stdoutPipe, gatewayLogs)
|
||||||
|
go scanPipe(stderrPipe, gatewayLogs)
|
||||||
|
|
||||||
|
// Wait for the process to exit in the background to avoid zombies
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
log.Printf("Gateway process exited: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": cmd.Process.Pid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
|
||||||
|
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
buf.Append(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
|
||||||
|
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
|
||||||
|
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
|
||||||
|
err = exec.Command("powershell", "-Command", psCmd).Run()
|
||||||
|
} else {
|
||||||
|
// Linux/macOS
|
||||||
|
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
|
||||||
|
// We still return 200 OK because pkill returns an error if no process was found
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok", // or "not_found"
|
||||||
|
"msg": "Stop command executed, but returned error (process might not be running).",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Stopped picoclaw gateway processes.\n")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
|
||||||
|
cfg, cfgErr := config.LoadConfig(absPath)
|
||||||
|
host := "127.0.0.1"
|
||||||
|
port := 18790
|
||||||
|
if cfgErr == nil && cfg != nil {
|
||||||
|
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||||
|
host = cfg.Gateway.Host
|
||||||
|
}
|
||||||
|
if cfg.Gateway.Port != 0 {
|
||||||
|
port = cfg.Gateway.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
client := http.Client{Timeout: 2 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
|
||||||
|
// Build the response data map
|
||||||
|
data := map[string]any{}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
data["process_status"] = "stopped"
|
||||||
|
data["error"] = err.Error()
|
||||||
|
} else {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
data["process_status"] = "error"
|
||||||
|
data["status_code"] = resp.StatusCode
|
||||||
|
} else {
|
||||||
|
var healthData map[string]any
|
||||||
|
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||||
|
data["process_status"] = "error"
|
||||||
|
data["error"] = "invalid response from gateway"
|
||||||
|
} else {
|
||||||
|
// Gateway is running and responded properly — merge health data
|
||||||
|
for k, v := range healthData {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data["process_status"] = "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append log data from the buffer
|
||||||
|
appendLogData(r, data)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendLogData reads log_offset and log_run_id query params from the request and
|
||||||
|
// populates the response data map with incremental log lines.
|
||||||
|
func appendLogData(r *http.Request, data map[string]any) {
|
||||||
|
clientOffset := 0
|
||||||
|
clientRunID := -1
|
||||||
|
|
||||||
|
if v := r.URL.Query().Get("log_offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
clientOffset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
clientRunID = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runID := gatewayLogs.RunID()
|
||||||
|
|
||||||
|
// If runID is 0 (never reset = never launched from this launcher), report no source
|
||||||
|
if runID == 0 {
|
||||||
|
data["logs"] = []string{}
|
||||||
|
data["log_total"] = 0
|
||||||
|
data["log_run_id"] = 0
|
||||||
|
data["log_source"] = "none"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
|
||||||
|
offset := clientOffset
|
||||||
|
if clientRunID != runID {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, total, runID := gatewayLogs.LinesSince(offset)
|
||||||
|
if lines == nil {
|
||||||
|
lines = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
data["logs"] = lines
|
||||||
|
data["log_total"] = total
|
||||||
|
data["log_run_id"] = runID
|
||||||
|
data["log_source"] = "launcher"
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPort = "18800"
|
||||||
|
|
||||||
|
// providerStatus represents the auth status of a single provider in API responses.
|
||||||
|
type providerStatus struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
AuthMethod string `json:"auth_method"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route registration ───────────────────────────────────────────
|
||||||
|
|
||||||
|
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
// GET /api/config — read config
|
||||||
|
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := config.LoadConfig(absPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
resp := map[string]any{
|
||||||
|
"config": cfg,
|
||||||
|
"path": absPath,
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(resp); err != nil {
|
||||||
|
log.Printf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/config — save config
|
||||||
|
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
var cfg config.Config
|
||||||
|
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(absPath, &cfg); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
// GET /api/auth/status — all authenticated providers + pending login state
|
||||||
|
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store, err := auth.LoadStore()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := []providerStatus{}
|
||||||
|
for name, cred := range store.Credentials {
|
||||||
|
status := "active"
|
||||||
|
if cred.IsExpired() {
|
||||||
|
status = "expired"
|
||||||
|
} else if cred.NeedsRefresh() {
|
||||||
|
status = "needs_refresh"
|
||||||
|
}
|
||||||
|
ps := providerStatus{
|
||||||
|
Provider: name,
|
||||||
|
AuthMethod: cred.AuthMethod,
|
||||||
|
Status: status,
|
||||||
|
AccountID: cred.AccountID,
|
||||||
|
Email: cred.Email,
|
||||||
|
ProjectID: cred.ProjectID,
|
||||||
|
}
|
||||||
|
if !cred.ExpiresAt.IsZero() {
|
||||||
|
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
result = append(result, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include pending device code state
|
||||||
|
var pendingDevice map[string]any
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
if activeDeviceSession != nil {
|
||||||
|
activeDeviceSession.mu.Lock()
|
||||||
|
pendingDevice = map[string]any{
|
||||||
|
"provider": activeDeviceSession.Provider,
|
||||||
|
"status": activeDeviceSession.Status,
|
||||||
|
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||||
|
"user_code": activeDeviceSession.Info.UserCode,
|
||||||
|
}
|
||||||
|
if activeDeviceSession.Error != "" {
|
||||||
|
pendingDevice["error"] = activeDeviceSession.Error
|
||||||
|
}
|
||||||
|
if activeDeviceSession.Done {
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
activeDeviceSession = nil
|
||||||
|
} else {
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"providers": result,
|
||||||
|
"pending_device": pendingDevice,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/login — initiate provider login
|
||||||
|
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Provider {
|
||||||
|
case "openai":
|
||||||
|
handleOpenAILogin(w, absPath)
|
||||||
|
case "anthropic":
|
||||||
|
handleAnthropicLogin(w, req.Token, absPath)
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
handleGoogleAntigravityLogin(w, r, absPath)
|
||||||
|
default:
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
|
||||||
|
req.Provider,
|
||||||
|
),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/logout — logout a provider
|
||||||
|
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Provider == "" {
|
||||||
|
if err := auth.DeleteAllCredentials(); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearAllAuthMethodsInConfig(absPath)
|
||||||
|
} else {
|
||||||
|
if err := auth.DeleteCredential(req.Provider); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearAuthMethodInConfig(absPath, req.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /auth/callback — OAuth browser callback for Google Antigravity
|
||||||
|
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Config API tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal config: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterConfigAPI(mux, path)
|
||||||
|
RegisterAuthAPI(mux, path)
|
||||||
|
return mux, path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mux, path := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Config config.Config `json:"config"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Path != path {
|
||||||
|
t.Errorf("expected path %q, got %q", path, resp.Path)
|
||||||
|
}
|
||||||
|
if len(resp.Config.ModelList) != 1 {
|
||||||
|
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// LoadConfig returns a default empty config when file is missing
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, path := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
newCfg := config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(newCfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := config.LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load saved config: %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
|
||||||
|
}
|
||||||
|
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||||
|
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutConfig_InvalidJSON(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth API tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAuthStatus(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/auth/status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Providers []providerStatus `json:"providers"`
|
||||||
|
PendingDevice map[string]any `json:"pending_device"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// providers should be a non-nil list (could be empty)
|
||||||
|
if resp.Providers == nil {
|
||||||
|
t.Error("providers should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
body := `{"provider": "unsupported"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
body := `{"provider": "anthropic"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_InvalidBody(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogout_InvalidBody(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid body, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthCallback_InvalidState(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid state, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDefaultConfigPath(t *testing.T) {
|
||||||
|
path := DefaultConfigPath()
|
||||||
|
if path == "" {
|
||||||
|
t.Error("defaultConfigPath should not return empty")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
|
||||||
|
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLocalIP(t *testing.T) {
|
||||||
|
// Just ensure it doesn't panic; IP may or may not be available
|
||||||
|
ip := GetLocalIP()
|
||||||
|
if ip != "" {
|
||||||
|
// If returned, should look like an IP
|
||||||
|
if !strings.Contains(ip, ".") {
|
||||||
|
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "config.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLocalIP() string {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||||
|
return ipnet.IP.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
|||||||
|
// PicoClaw Launcher - Standalone HTTP service
|
||||||
|
//
|
||||||
|
// Provides a web-based JSON editor for picoclaw config files,
|
||||||
|
// with OAuth provider authentication support.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
// ./picoclaw-launcher [config.json]
|
||||||
|
// ./picoclaw-launcher -public config.json
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed internal/ui/index.html
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||||
|
fmt.Fprintf(
|
||||||
|
os.Stderr,
|
||||||
|
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||||
|
os.Args[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
configPath := server.DefaultConfigPath()
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
configPath = flag.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to resolve config path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var addr string
|
||||||
|
if *public {
|
||||||
|
addr = "0.0.0.0:" + server.DefaultPort
|
||||||
|
} else {
|
||||||
|
addr = "127.0.0.1:" + server.DefaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
server.RegisterConfigAPI(mux, absPath)
|
||||||
|
server.RegisterAuthAPI(mux, absPath)
|
||||||
|
server.RegisterProcessAPI(mux, absPath)
|
||||||
|
|
||||||
|
staticFS, err := fs.Sub(staticFiles, "internal/ui")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create sub filesystem: %v", err)
|
||||||
|
}
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||||
|
|
||||||
|
// Print startup banner
|
||||||
|
fmt.Println("=============================================")
|
||||||
|
fmt.Println(" PicoClaw Launcher")
|
||||||
|
fmt.Println("=============================================")
|
||||||
|
fmt.Printf(" Config file : %s\n", absPath)
|
||||||
|
fmt.Printf(" Listen addr : %s\n\n", addr)
|
||||||
|
fmt.Println(" Open the following URL in your browser")
|
||||||
|
fmt.Println(" to view and edit the configuration:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
|
||||||
|
if *public {
|
||||||
|
if ip := server.GetLocalIP(); ip != "" {
|
||||||
|
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
// fmt.Println("=============================================")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Wait briefly to ensure the server is ready before opening the browser
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
url := "http://localhost:" + server.DefaultPort
|
||||||
|
if err := openBrowser(url); err != nil {
|
||||||
|
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser automatically opens the given URL in the default browser.
|
||||||
|
func openBrowser(url string) error {
|
||||||
|
var err error
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
err = exec.Command("xdg-open", url).Start()
|
||||||
|
case "windows":
|
||||||
|
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||||
|
case "darwin":
|
||||||
|
err = exec.Command("open", url).Start()
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported platform")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"RT_GROUP_ICON": {
|
||||||
|
"APP": {
|
||||||
|
"0000": "../icon.ico"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RT_MANIFEST": {
|
||||||
|
"#1": {
|
||||||
|
"0409": {
|
||||||
|
"identity": {
|
||||||
|
"name": "PicoClaw Launcher",
|
||||||
|
"version": "0.0.0.0"
|
||||||
|
},
|
||||||
|
"description": "PicoClaw Launcher - Web-based configuration editor",
|
||||||
|
"minimum-os": "win7",
|
||||||
|
"execution-level": "asInvoker",
|
||||||
|
"dpi-awareness": "system",
|
||||||
|
"use-common-controls-v6": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package gateway
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -175,6 +176,7 @@ func gatewayCmd(debug bool) error {
|
|||||||
|
|
||||||
if err := channelManager.StartAll(ctx); err != nil {
|
if err := channelManager.StartAll(ctx); err != nil {
|
||||||
fmt.Printf("Error starting channels: %v\n", err)
|
fmt.Printf("Error starting channels: %v\n", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
@@ -222,7 +224,11 @@ func setupCronTool(
|
|||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool
|
||||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
agentLoop.RegisterTool(cronTool)
|
agentLoop.RegisterTool(cronTool)
|
||||||
|
|
||||||
// Set the onJob handler
|
// Set the onJob handler
|
||||||
|
|||||||
@@ -11,19 +11,21 @@ func NewMigrateCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "migrate",
|
Use: "migrate",
|
||||||
Short: "Migrate from OpenClaw to PicoClaw",
|
Short: "Migrate from xxxclaw(openclaw, etc.) to picoclaw",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
Example: ` picoclaw migrate
|
Example: ` picoclaw migrate
|
||||||
|
picoclaw migrate --from openclaw
|
||||||
picoclaw migrate --dry-run
|
picoclaw migrate --dry-run
|
||||||
picoclaw migrate --refresh
|
picoclaw migrate --refresh
|
||||||
picoclaw migrate --force`,
|
picoclaw migrate --force`,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
result, err := migrate.Run(opts)
|
m := migrate.NewMigrateInstance(opts)
|
||||||
|
result, err := m.Run(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !opts.DryRun {
|
if !opts.DryRun {
|
||||||
migrate.PrintSummary(result)
|
m.PrintSummary(result)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -31,6 +33,8 @@ func NewMigrateCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
"Show what would be migrated without making changes")
|
"Show what would be migrated without making changes")
|
||||||
|
cmd.Flags().StringVar(&opts.Source, "from", "openclaw",
|
||||||
|
"Source to migrate from (e.g., openclaw)")
|
||||||
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
||||||
"Re-sync workspace files from OpenClaw (repeatable)")
|
"Re-sync workspace files from OpenClaw (repeatable)")
|
||||||
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
||||||
@@ -39,10 +43,10 @@ func NewMigrateCommand() *cobra.Command {
|
|||||||
"Only migrate workspace files, skip config")
|
"Only migrate workspace files, skip config")
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
||||||
"Skip confirmation prompts")
|
"Skip confirmation prompts")
|
||||||
cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
|
cmd.Flags().StringVar(&opts.SourceHome, "source-home", "",
|
||||||
"Override OpenClaw home directory (default: ~/.openclaw)")
|
"Override source home directory (default: ~/.openclaw)")
|
||||||
cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
|
cmd.Flags().StringVar(&opts.TargetHome, "target-home", "",
|
||||||
"Override PicoClaw home directory (default: ~/.picoclaw)")
|
"Override target home directory (default: ~/.picoclaw)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestNewMigrateCommand(t *testing.T) {
|
|||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "migrate", cmd.Use)
|
assert.Equal(t, "migrate", cmd.Use)
|
||||||
assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
|
assert.Equal(t, "Migrate from xxxclaw(openclaw, etc.) to picoclaw", cmd.Short)
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ func TestNewMigrateCommand(t *testing.T) {
|
|||||||
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
|
assert.NotNil(t, cmd.Flags().Lookup("source-home"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
|
assert.NotNil(t, cmd.Flags().Lookup("target-home"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Issue #783 调研与修复执行文档
|
||||||
|
|
||||||
|
## 1. 问题澄清(已确认)
|
||||||
|
|
||||||
|
- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
|
||||||
|
- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
|
||||||
|
- 影响:
|
||||||
|
- fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。
|
||||||
|
- `defaults.provider` 为空时,日志出现 `provider=` 空值。
|
||||||
|
|
||||||
|
## 2. 本次目标
|
||||||
|
|
||||||
|
- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
|
||||||
|
- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
|
||||||
|
- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
|
||||||
|
- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
|
||||||
|
|
||||||
|
## 3. 联网最佳实践调研结论(已完成)
|
||||||
|
|
||||||
|
- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。
|
||||||
|
- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
|
||||||
|
- [x] 将外部建议映射为本仓库可执行约束。
|
||||||
|
|
||||||
|
外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
|
||||||
|
|
||||||
|
- 优先显式配置,不依赖字符串切分推断 provider。
|
||||||
|
- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
|
||||||
|
- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
|
||||||
|
|
||||||
|
参考链接:
|
||||||
|
|
||||||
|
- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
|
||||||
|
- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
|
||||||
|
- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
|
||||||
|
- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
|
||||||
|
- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
|
||||||
|
|
||||||
|
与本仓库对应的可执行约束:
|
||||||
|
|
||||||
|
- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
|
||||||
|
- 未命中映射时保留旧解析行为,保证兼容性。
|
||||||
|
- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
|
||||||
|
|
||||||
|
## 4. 实施步骤(顺序执行)
|
||||||
|
|
||||||
|
- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
|
||||||
|
- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
|
||||||
|
- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
|
||||||
|
- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
|
||||||
|
- [x] Step 5: 运行质量门禁(LSP + `make check`)。
|
||||||
|
|
||||||
|
## 5. 执行记录
|
||||||
|
|
||||||
|
- 状态:已完成
|
||||||
|
- 已完成改动:
|
||||||
|
- `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
|
||||||
|
- `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
|
||||||
|
- `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
|
||||||
|
- `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
|
||||||
|
- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。
|
||||||
|
- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。
|
||||||
@@ -33,13 +33,18 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/tview v0.42.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L
|
|||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
@@ -113,6 +117,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
@@ -148,6 +154,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||||
|
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
|||||||
+48
-2
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -51,7 +52,12 @@ func NewAgentInstance(
|
|||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
||||||
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
|
}
|
||||||
|
toolsRegistry.Register(execTool)
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
||||||
|
|
||||||
@@ -92,7 +98,47 @@ func NewAgentInstance(
|
|||||||
Primary: model,
|
Primary: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
}
|
}
|
||||||
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
|
ensureProtocol := func(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return "openai/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
|
return ensureProtocol(mc.Model), true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||||
|
if fullModel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fullModel == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
if modelID == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
|
|||||||
@@ -93,3 +93,77 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
|||||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "step-3.5-flash",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "step-3.5-flash",
|
||||||
|
Model: "openrouter/stepfun/step-3.5-flash:free",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != "openrouter" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "glm-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "glm-5",
|
||||||
|
Model: "glm-5",
|
||||||
|
APIBase: "https://api.z.ai/api/coding/paas/v4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != "openai" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != "glm-5" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+56
-5
@@ -9,6 +9,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -574,11 +575,36 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
// Use a short timeout so the goroutine does not block indefinitely when
|
||||||
|
// the outbound bus is full. Reasoning output is best-effort; dropping it
|
||||||
|
// is acceptable to avoid goroutine accumulation.
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
|
||||||
|
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: channelName,
|
Channel: channelName,
|
||||||
ChatID: channelID,
|
ChatID: channelID,
|
||||||
Content: reasoningContent,
|
Content: reasoningContent,
|
||||||
})
|
}); err != nil {
|
||||||
|
// Treat context.DeadlineExceeded / context.Canceled as expected
|
||||||
|
// (bus full under load, or parent canceled). Check the error
|
||||||
|
// itself rather than ctx.Err(), because pubCtx may time out
|
||||||
|
// (5 s) while the parent ctx is still active.
|
||||||
|
// Also treat ErrBusClosed as expected — it occurs during normal
|
||||||
|
// shutdown when the bus is closed before all goroutines finish.
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||||
|
errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{
|
||||||
|
"channel": channelName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{
|
||||||
|
"channel": channelName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runLLMIteration executes the LLM call loop with tool handling.
|
// runLLMIteration executes the LLM call loop with tool handling.
|
||||||
@@ -666,10 +692,35 @@ func (al *AgentLoop) runLLMIteration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
errMsg := strings.ToLower(err.Error())
|
errMsg := strings.ToLower(err.Error())
|
||||||
isContextError := strings.Contains(errMsg, "token") ||
|
|
||||||
strings.Contains(errMsg, "context") ||
|
// Check if this is a network/HTTP timeout — not a context window error.
|
||||||
|
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
|
||||||
|
strings.Contains(errMsg, "deadline exceeded") ||
|
||||||
|
strings.Contains(errMsg, "client.timeout") ||
|
||||||
|
strings.Contains(errMsg, "timed out") ||
|
||||||
|
strings.Contains(errMsg, "timeout exceeded")
|
||||||
|
|
||||||
|
// Detect real context window / token limit errors, excluding network timeouts.
|
||||||
|
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
||||||
|
strings.Contains(errMsg, "context window") ||
|
||||||
|
strings.Contains(errMsg, "maximum context length") ||
|
||||||
|
strings.Contains(errMsg, "token limit") ||
|
||||||
|
strings.Contains(errMsg, "too many tokens") ||
|
||||||
|
strings.Contains(errMsg, "max_tokens") ||
|
||||||
strings.Contains(errMsg, "invalidparameter") ||
|
strings.Contains(errMsg, "invalidparameter") ||
|
||||||
strings.Contains(errMsg, "length")
|
strings.Contains(errMsg, "prompt is too long") ||
|
||||||
|
strings.Contains(errMsg, "request too large"))
|
||||||
|
|
||||||
|
if isTimeoutError && retry < maxRetries {
|
||||||
|
backoff := time.Duration(retry+1) * 5 * time.Second
|
||||||
|
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"retry": retry,
|
||||||
|
"backoff": backoff.String(),
|
||||||
|
})
|
||||||
|
time.Sleep(backoff)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if isContextError && retry < maxRetries {
|
if isContextError && retry < maxRetries {
|
||||||
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
|
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
|
||||||
|
|||||||
@@ -797,4 +797,57 @@ func TestHandleReasoning(t *testing.T) {
|
|||||||
t.Fatalf("expected no outbound message, got %+v", msg)
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("returns promptly when bus is full", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
|
||||||
|
// Fill the outbound bus buffer until a publish would block.
|
||||||
|
// Use a short timeout to detect when the buffer is full,
|
||||||
|
// rather than hardcoding the buffer size.
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{
|
||||||
|
Channel: "filler",
|
||||||
|
ChatID: "filler",
|
||||||
|
Content: fmt.Sprintf("filler-%d", i),
|
||||||
|
})
|
||||||
|
fillCancel()
|
||||||
|
if err != nil {
|
||||||
|
// Buffer is full (timed out trying to send).
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a short-deadline parent context to bound the test.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
al.handleReasoning(ctx, "should timeout", "slack", "channel-full")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// handleReasoning uses a 5s internal timeout, but the parent ctx
|
||||||
|
// expires in 500ms. It should return within ~500ms, not 5s.
|
||||||
|
if elapsed > 2*time.Second {
|
||||||
|
t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the bus and verify the reasoning message was NOT published
|
||||||
|
// (it should have been dropped due to timeout).
|
||||||
|
drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer drainCancel()
|
||||||
|
foundReasoning := false
|
||||||
|
for {
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(drainCtx)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if msg.Content == "should timeout" {
|
||||||
|
foundReasoning = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundReasoning {
|
||||||
|
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-8
@@ -66,7 +66,8 @@ func decodeBase64(s string) string {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateState() (string, error) {
|
// GenerateState generates a random state string for OAuth CSRF protection.
|
||||||
|
func GenerateState() (string, error) {
|
||||||
buf := make([]byte, 32)
|
buf := make([]byte, 32)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||||||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := generateState()
|
state, err := GenerateState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generating state: %w", err)
|
return nil, fmt.Errorf("generating state: %w", err)
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||||||
|
|
||||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
||||||
|
|
||||||
if err := openBrowser(authURL); err != nil {
|
if err := OpenBrowser(authURL); err != nil {
|
||||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||||||
if result.err != nil {
|
if result.err != nil {
|
||||||
return nil, result.err
|
return nil, result.err
|
||||||
}
|
}
|
||||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||||
case manualInput := <-manualCh:
|
case manualInput := <-manualCh:
|
||||||
if manualInput == "" {
|
if manualInput == "" {
|
||||||
return nil, fmt.Errorf("manual input canceled")
|
return nil, fmt.Errorf("manual input canceled")
|
||||||
@@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("could not find authorization code in input")
|
return nil, fmt.Errorf("could not find authorization code in input")
|
||||||
}
|
}
|
||||||
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||||
case <-time.After(5 * time.Minute):
|
case <-time.After(5 * time.Minute):
|
||||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||||
}
|
}
|
||||||
@@ -186,6 +187,59 @@ type deviceCodeResponse struct {
|
|||||||
Interval int
|
Interval int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceCodeInfo holds the device code information returned by the OAuth provider.
|
||||||
|
type DeviceCodeInfo struct {
|
||||||
|
DeviceAuthID string `json:"device_auth_id"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerifyURL string `json:"verify_url"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDeviceCode requests a device code from the OAuth provider.
|
||||||
|
// Returns the info needed for the user to authenticate in a browser.
|
||||||
|
func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{
|
||||||
|
"client_id": cfg.ClientID,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := http.Post(
|
||||||
|
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(string(reqBody)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("requesting device code: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceResp, err := parseDeviceCodeResponse(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing device code response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deviceResp.Interval < 1 {
|
||||||
|
deviceResp.Interval = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DeviceCodeInfo{
|
||||||
|
DeviceAuthID: deviceResp.DeviceAuthID,
|
||||||
|
UserCode: deviceResp.UserCode,
|
||||||
|
VerifyURL: cfg.Issuer + "/codex/device",
|
||||||
|
Interval: deviceResp.Interval,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.
|
||||||
|
// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.
|
||||||
|
func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
||||||
|
return pollDeviceCode(cfg, deviceAuthID, userCode)
|
||||||
|
}
|
||||||
|
|
||||||
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
||||||
var raw struct {
|
var raw struct {
|
||||||
DeviceAuthID string `json:"device_auth_id"`
|
DeviceAuthID string `json:"device_auth_id"`
|
||||||
@@ -318,7 +372,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
||||||
return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
@@ -410,7 +464,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
|||||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
// ExchangeCodeForTokens exchanges an authorization code for tokens.
|
||||||
|
func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||||
data := url.Values{
|
data := url.Values{
|
||||||
"grant_type": {"authorization_code"},
|
"grant_type": {"authorization_code"},
|
||||||
"code": {code},
|
"code": {code},
|
||||||
@@ -552,7 +607,8 @@ func base64URLDecode(s string) ([]byte, error) {
|
|||||||
return base64.StdEncoding.DecodeString(s)
|
return base64.StdEncoding.DecodeString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openBrowser(url string) error {
|
// OpenBrowser opens the given URL in the user's default browser.
|
||||||
|
func OpenBrowser(url string) error {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return exec.Command("open", url).Start()
|
return exec.Command("open", url).Start()
|
||||||
|
|||||||
@@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
|||||||
Port: 1455,
|
Port: 1455,
|
||||||
}
|
}
|
||||||
|
|
||||||
cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
t.Fatalf("ExchangeCodeForTokens() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cred.AccessToken != "mock-access-token" {
|
if cred.AccessToken != "mock-access-token" {
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||||||
|
|
||||||
if len(m.channels) == 0 {
|
if len(m.channels) == 0 {
|
||||||
logger.WarnC("channels", "No channels enabled")
|
logger.WarnC("channels", "No channels enabled")
|
||||||
return nil
|
return errors.New("no channels enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoC("channels", "Starting all channels")
|
logger.InfoC("channels", "Starting all channels")
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
|||||||
if provider == "" {
|
if provider == "" {
|
||||||
provider = "configured default"
|
provider = "configured default"
|
||||||
}
|
}
|
||||||
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
|
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
||||||
c.config.Agents.Defaults.GetModelName(), provider)
|
c.config.Agents.Defaults.GetModelName(), provider)
|
||||||
|
|
||||||
case "channels":
|
case "channels":
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mdp/qrterminal/v3"
|
"github.com/mdp/qrterminal/v3"
|
||||||
@@ -56,6 +57,8 @@ type WhatsAppNativeChannel struct {
|
|||||||
runCancel context.CancelFunc
|
runCancel context.CancelFunc
|
||||||
reconnectMu sync.Mutex
|
reconnectMu sync.Mutex
|
||||||
reconnecting bool
|
reconnecting bool
|
||||||
|
stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
|
||||||
|
wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
|
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
|
||||||
@@ -80,6 +83,14 @@ func NewWhatsAppNativeChannel(
|
|||||||
func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
|
logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
|
||||||
|
|
||||||
|
// Reset lifecycle state from any previous Stop() so a restarted channel
|
||||||
|
// behaves correctly. Use reconnectMu to be consistent with eventHandler
|
||||||
|
// and Stop() which coordinate under the same lock.
|
||||||
|
c.reconnectMu.Lock()
|
||||||
|
c.stopping.Store(false)
|
||||||
|
c.reconnecting = false
|
||||||
|
c.reconnectMu.Unlock()
|
||||||
|
|
||||||
if err := os.MkdirAll(c.storePath, 0o700); err != nil {
|
if err := os.MkdirAll(c.storePath, 0o700); err != nil {
|
||||||
return fmt.Errorf("create session store dir: %w", err)
|
return fmt.Errorf("create session store dir: %w", err)
|
||||||
}
|
}
|
||||||
@@ -112,6 +123,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := whatsmeow.NewClient(deviceStore, waLogger)
|
client := whatsmeow.NewClient(deviceStore, waLogger)
|
||||||
|
|
||||||
|
// Create runCtx/runCancel BEFORE registering event handler and starting
|
||||||
|
// goroutines so that Stop() can cancel them at any time, including during
|
||||||
|
// the QR-login flow.
|
||||||
|
c.runCtx, c.runCancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
client.AddEventHandler(c.eventHandler)
|
client.AddEventHandler(c.eventHandler)
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -119,36 +136,75 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
|||||||
c.client = client
|
c.client = client
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// cleanupOnError clears struct references and releases resources when
|
||||||
|
// Start() fails after fields are already assigned. This prevents
|
||||||
|
// Stop() from operating on stale references (double-close, disconnect
|
||||||
|
// of a partially-initialized client, or stray event handler callbacks).
|
||||||
|
startOK := false
|
||||||
|
defer func() {
|
||||||
|
if startOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runCancel()
|
||||||
|
client.Disconnect()
|
||||||
|
c.mu.Lock()
|
||||||
|
c.client = nil
|
||||||
|
c.container = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
_ = container.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
if client.Store.ID == nil {
|
if client.Store.ID == nil {
|
||||||
qrChan, err := client.GetQRChannel(ctx)
|
qrChan, err := client.GetQRChannel(c.runCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = container.Close()
|
|
||||||
return fmt.Errorf("get QR channel: %w", err)
|
return fmt.Errorf("get QR channel: %w", err)
|
||||||
}
|
}
|
||||||
if err := client.Connect(); err != nil {
|
if err := client.Connect(); err != nil {
|
||||||
_ = container.Close()
|
|
||||||
return fmt.Errorf("connect: %w", err)
|
return fmt.Errorf("connect: %w", err)
|
||||||
}
|
}
|
||||||
for evt := range qrChan {
|
// Handle QR events in a background goroutine so Start() returns
|
||||||
if evt.Event == "code" {
|
// promptly. The goroutine is tracked via c.wg and respects
|
||||||
logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
|
// c.runCtx for cancellation.
|
||||||
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
|
// Guard wg.Add with reconnectMu + stopping check (same protocol
|
||||||
Level: qrterminal.L,
|
// as eventHandler) so a concurrent Stop() cannot enter wg.Wait()
|
||||||
Writer: os.Stdout,
|
// while we call wg.Add(1).
|
||||||
HalfBlocks: true,
|
c.reconnectMu.Lock()
|
||||||
})
|
if c.stopping.Load() {
|
||||||
} else {
|
c.reconnectMu.Unlock()
|
||||||
logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
|
return fmt.Errorf("channel stopped during QR setup")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
c.wg.Add(1)
|
||||||
|
c.reconnectMu.Unlock()
|
||||||
|
go func() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.runCtx.Done():
|
||||||
|
return
|
||||||
|
case evt, ok := <-qrChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if evt.Event == "code" {
|
||||||
|
logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
|
||||||
|
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
|
||||||
|
Level: qrterminal.L,
|
||||||
|
Writer: os.Stdout,
|
||||||
|
HalfBlocks: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
} else {
|
} else {
|
||||||
if err := client.Connect(); err != nil {
|
if err := client.Connect(); err != nil {
|
||||||
_ = container.Close()
|
|
||||||
return fmt.Errorf("connect: %w", err)
|
return fmt.Errorf("connect: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.runCtx, c.runCancel = context.WithCancel(ctx)
|
startOK = true
|
||||||
c.SetRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("whatsapp", "WhatsApp native channel connected")
|
logger.InfoC("whatsapp", "WhatsApp native channel connected")
|
||||||
return nil
|
return nil
|
||||||
@@ -156,19 +212,53 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
|||||||
|
|
||||||
func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
|
func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("whatsapp", "Stopping WhatsApp native channel")
|
logger.InfoC("whatsapp", "Stopping WhatsApp native channel")
|
||||||
|
|
||||||
|
// Mark as stopping under reconnectMu so the flag is visible to
|
||||||
|
// eventHandler atomically with respect to its wg.Add(1) call.
|
||||||
|
// This closes the TOCTOU window where eventHandler could check
|
||||||
|
// stopping (false), then Stop sets it true + enters wg.Wait,
|
||||||
|
// then eventHandler calls wg.Add(1) — causing a panic.
|
||||||
|
c.reconnectMu.Lock()
|
||||||
|
c.stopping.Store(true)
|
||||||
|
c.reconnectMu.Unlock()
|
||||||
|
|
||||||
if c.runCancel != nil {
|
if c.runCancel != nil {
|
||||||
c.runCancel()
|
c.runCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disconnect the client first so any blocking Connect()/reconnect loops
|
||||||
|
// can be interrupted before we wait on the goroutines.
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
client := c.client
|
client := c.client
|
||||||
container := c.container
|
container := c.container
|
||||||
c.client = nil
|
|
||||||
c.container = nil
|
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if client != nil {
|
if client != nil {
|
||||||
client.Disconnect()
|
client.Disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for background goroutines (QR handler, reconnect) to finish in a
|
||||||
|
// context-aware way so Stop can be bounded by ctx.
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
c.wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// All goroutines have finished.
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Context canceled or timed out; log and proceed with best-effort cleanup.
|
||||||
|
logger.WarnC("whatsapp", fmt.Sprintf("Stop context canceled before all goroutines finished: %v", ctx.Err()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now it is safe to clear and close resources.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.client = nil
|
||||||
|
c.container = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
if container != nil {
|
if container != nil {
|
||||||
_ = container.Close()
|
_ = container.Close()
|
||||||
}
|
}
|
||||||
@@ -187,9 +277,20 @@ func (c *WhatsAppNativeChannel) eventHandler(evt any) {
|
|||||||
c.reconnectMu.Unlock()
|
c.reconnectMu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Check stopping while holding the lock so the check and wg.Add
|
||||||
|
// are atomic with respect to Stop() setting the flag + calling
|
||||||
|
// wg.Wait(). This prevents the TOCTOU race.
|
||||||
|
if c.stopping.Load() {
|
||||||
|
c.reconnectMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
c.reconnecting = true
|
c.reconnecting = true
|
||||||
|
c.wg.Add(1)
|
||||||
c.reconnectMu.Unlock()
|
c.reconnectMu.Unlock()
|
||||||
go c.reconnectWithBackoff()
|
go func() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
c.reconnectWithBackoff()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +414,12 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
|
|||||||
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
|
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect unpaired state: the client is connected (to WhatsApp servers)
|
||||||
|
// but has not completed QR-login yet, so sending would fail.
|
||||||
|
if client.Store.ID == nil {
|
||||||
|
return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
|
||||||
to, err := parseJID(msg.ChatID)
|
to, err := parseJID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
|
return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ type AgentDefaults struct {
|
|||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||||
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -324,6 +325,25 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "config.json")
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := SaveConfig(path, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), `"model": ""`) {
|
||||||
|
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfig_Complete verifies all config fields are set
|
// TestConfig_Complete verifies all config fields are set
|
||||||
func TestConfig_Complete(t *testing.T) {
|
func TestConfig_Complete(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|||||||
@@ -1,414 +0,0 @@
|
|||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var supportedProviders = map[string]bool{
|
|
||||||
"anthropic": true,
|
|
||||||
"openai": true,
|
|
||||||
"openrouter": true,
|
|
||||||
"groq": true,
|
|
||||||
"zhipu": true,
|
|
||||||
"vllm": true,
|
|
||||||
"gemini": true,
|
|
||||||
"qwen": true,
|
|
||||||
"deepseek": true,
|
|
||||||
"github_copilot": true,
|
|
||||||
"mistral": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var supportedChannels = map[string]bool{
|
|
||||||
"telegram": true,
|
|
||||||
"discord": true,
|
|
||||||
"whatsapp": true,
|
|
||||||
"feishu": true,
|
|
||||||
"qq": true,
|
|
||||||
"dingtalk": true,
|
|
||||||
"maixcam": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
func findOpenClawConfig(openclawHome string) (string, error) {
|
|
||||||
candidates := []string{
|
|
||||||
filepath.Join(openclawHome, "openclaw.json"),
|
|
||||||
filepath.Join(openclawHome, "config.json"),
|
|
||||||
}
|
|
||||||
for _, p := range candidates {
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadOpenClawConfig(configPath string) (map[string]any, error) {
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var raw map[string]any
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
converted := convertKeysToSnake(raw)
|
|
||||||
result, ok := converted.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected config format")
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
if agents, ok := getMap(data, "agents"); ok {
|
|
||||||
if defaults, ok := getMap(agents, "defaults"); ok {
|
|
||||||
// Prefer model_name, fallback to model for backward compatibility
|
|
||||||
if v, ok := getString(defaults, "model_name"); ok {
|
|
||||||
cfg.Agents.Defaults.ModelName = v
|
|
||||||
} else if v, ok := getString(defaults, "model"); ok {
|
|
||||||
cfg.Agents.Defaults.Model = v
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(defaults, "max_tokens"); ok {
|
|
||||||
cfg.Agents.Defaults.MaxTokens = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(defaults, "temperature"); ok {
|
|
||||||
cfg.Agents.Defaults.Temperature = &v
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
|
|
||||||
cfg.Agents.Defaults.MaxToolIterations = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := getString(defaults, "workspace"); ok {
|
|
||||||
cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if providers, ok := getMap(data, "providers"); ok {
|
|
||||||
for name, val := range providers {
|
|
||||||
pMap, ok := val.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
apiKey, _ := getString(pMap, "api_key")
|
|
||||||
apiBase, _ := getString(pMap, "api_base")
|
|
||||||
|
|
||||||
if !supportedProviders[name] {
|
|
||||||
if apiKey != "" || apiBase != "" {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Provider '%s' not supported in PicoClaw, skipping", name))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase}
|
|
||||||
switch name {
|
|
||||||
case "anthropic":
|
|
||||||
cfg.Providers.Anthropic = pc
|
|
||||||
case "openai":
|
|
||||||
cfg.Providers.OpenAI = config.OpenAIProviderConfig{
|
|
||||||
ProviderConfig: pc,
|
|
||||||
WebSearch: getBoolOrDefault(pMap, "web_search", true),
|
|
||||||
}
|
|
||||||
case "openrouter":
|
|
||||||
cfg.Providers.OpenRouter = pc
|
|
||||||
case "groq":
|
|
||||||
cfg.Providers.Groq = pc
|
|
||||||
case "zhipu":
|
|
||||||
cfg.Providers.Zhipu = pc
|
|
||||||
case "vllm":
|
|
||||||
cfg.Providers.VLLM = pc
|
|
||||||
case "gemini":
|
|
||||||
cfg.Providers.Gemini = pc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if channels, ok := getMap(data, "channels"); ok {
|
|
||||||
for name, val := range channels {
|
|
||||||
cMap, ok := val.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !supportedChannels[name] {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in PicoClaw, skipping", name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
enabled, _ := getBool(cMap, "enabled")
|
|
||||||
allowFrom := getStringSlice(cMap, "allow_from")
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "telegram":
|
|
||||||
cfg.Channels.Telegram.Enabled = enabled
|
|
||||||
cfg.Channels.Telegram.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "token"); ok {
|
|
||||||
cfg.Channels.Telegram.Token = v
|
|
||||||
}
|
|
||||||
case "discord":
|
|
||||||
cfg.Channels.Discord.Enabled = enabled
|
|
||||||
cfg.Channels.Discord.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "token"); ok {
|
|
||||||
cfg.Channels.Discord.Token = v
|
|
||||||
}
|
|
||||||
case "whatsapp":
|
|
||||||
cfg.Channels.WhatsApp.Enabled = enabled
|
|
||||||
cfg.Channels.WhatsApp.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "bridge_url"); ok {
|
|
||||||
cfg.Channels.WhatsApp.BridgeURL = v
|
|
||||||
}
|
|
||||||
if v, ok := getBool(cMap, "use_native"); ok {
|
|
||||||
cfg.Channels.WhatsApp.UseNative = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "session_store_path"); ok {
|
|
||||||
cfg.Channels.WhatsApp.SessionStorePath = v
|
|
||||||
}
|
|
||||||
case "feishu":
|
|
||||||
cfg.Channels.Feishu.Enabled = enabled
|
|
||||||
cfg.Channels.Feishu.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "app_id"); ok {
|
|
||||||
cfg.Channels.Feishu.AppID = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "app_secret"); ok {
|
|
||||||
cfg.Channels.Feishu.AppSecret = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "encrypt_key"); ok {
|
|
||||||
cfg.Channels.Feishu.EncryptKey = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "verification_token"); ok {
|
|
||||||
cfg.Channels.Feishu.VerificationToken = v
|
|
||||||
}
|
|
||||||
case "qq":
|
|
||||||
cfg.Channels.QQ.Enabled = enabled
|
|
||||||
cfg.Channels.QQ.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "app_id"); ok {
|
|
||||||
cfg.Channels.QQ.AppID = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "app_secret"); ok {
|
|
||||||
cfg.Channels.QQ.AppSecret = v
|
|
||||||
}
|
|
||||||
case "dingtalk":
|
|
||||||
cfg.Channels.DingTalk.Enabled = enabled
|
|
||||||
cfg.Channels.DingTalk.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "client_id"); ok {
|
|
||||||
cfg.Channels.DingTalk.ClientID = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "client_secret"); ok {
|
|
||||||
cfg.Channels.DingTalk.ClientSecret = v
|
|
||||||
}
|
|
||||||
case "maixcam":
|
|
||||||
cfg.Channels.MaixCam.Enabled = enabled
|
|
||||||
cfg.Channels.MaixCam.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "host"); ok {
|
|
||||||
cfg.Channels.MaixCam.Host = v
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(cMap, "port"); ok {
|
|
||||||
cfg.Channels.MaixCam.Port = int(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if gateway, ok := getMap(data, "gateway"); ok {
|
|
||||||
if v, ok := getString(gateway, "host"); ok {
|
|
||||||
cfg.Gateway.Host = v
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(gateway, "port"); ok {
|
|
||||||
cfg.Gateway.Port = int(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tools, ok := getMap(data, "tools"); ok {
|
|
||||||
if web, ok := getMap(tools, "web"); ok {
|
|
||||||
// Migrate old "search" config to "brave" if api_key is present
|
|
||||||
if search, ok := getMap(web, "search"); ok {
|
|
||||||
if v, ok := getString(search, "api_key"); ok {
|
|
||||||
cfg.Tools.Web.Brave.APIKey = v
|
|
||||||
if v != "" {
|
|
||||||
cfg.Tools.Web.Brave.Enabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(search, "max_results"); ok {
|
|
||||||
cfg.Tools.Web.Brave.MaxResults = int(v)
|
|
||||||
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, warnings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func MergeConfig(existing, incoming *config.Config) *config.Config {
|
|
||||||
if existing.Providers.Anthropic.APIKey == "" {
|
|
||||||
existing.Providers.Anthropic = incoming.Providers.Anthropic
|
|
||||||
}
|
|
||||||
if existing.Providers.OpenAI.APIKey == "" {
|
|
||||||
existing.Providers.OpenAI = incoming.Providers.OpenAI
|
|
||||||
}
|
|
||||||
if existing.Providers.OpenRouter.APIKey == "" {
|
|
||||||
existing.Providers.OpenRouter = incoming.Providers.OpenRouter
|
|
||||||
}
|
|
||||||
if existing.Providers.Groq.APIKey == "" {
|
|
||||||
existing.Providers.Groq = incoming.Providers.Groq
|
|
||||||
}
|
|
||||||
if existing.Providers.Zhipu.APIKey == "" {
|
|
||||||
existing.Providers.Zhipu = incoming.Providers.Zhipu
|
|
||||||
}
|
|
||||||
if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" {
|
|
||||||
existing.Providers.VLLM = incoming.Providers.VLLM
|
|
||||||
}
|
|
||||||
if existing.Providers.Gemini.APIKey == "" {
|
|
||||||
existing.Providers.Gemini = incoming.Providers.Gemini
|
|
||||||
}
|
|
||||||
if existing.Providers.DeepSeek.APIKey == "" {
|
|
||||||
existing.Providers.DeepSeek = incoming.Providers.DeepSeek
|
|
||||||
}
|
|
||||||
if existing.Providers.GitHubCopilot.APIBase == "" {
|
|
||||||
existing.Providers.GitHubCopilot = incoming.Providers.GitHubCopilot
|
|
||||||
}
|
|
||||||
if existing.Providers.Qwen.APIKey == "" {
|
|
||||||
existing.Providers.Qwen = incoming.Providers.Qwen
|
|
||||||
}
|
|
||||||
|
|
||||||
if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
|
|
||||||
existing.Channels.Telegram = incoming.Channels.Telegram
|
|
||||||
}
|
|
||||||
if !existing.Channels.Discord.Enabled && incoming.Channels.Discord.Enabled {
|
|
||||||
existing.Channels.Discord = incoming.Channels.Discord
|
|
||||||
}
|
|
||||||
if !existing.Channels.WhatsApp.Enabled && incoming.Channels.WhatsApp.Enabled {
|
|
||||||
existing.Channels.WhatsApp = incoming.Channels.WhatsApp
|
|
||||||
}
|
|
||||||
if !existing.Channels.Feishu.Enabled && incoming.Channels.Feishu.Enabled {
|
|
||||||
existing.Channels.Feishu = incoming.Channels.Feishu
|
|
||||||
}
|
|
||||||
if !existing.Channels.QQ.Enabled && incoming.Channels.QQ.Enabled {
|
|
||||||
existing.Channels.QQ = incoming.Channels.QQ
|
|
||||||
}
|
|
||||||
if !existing.Channels.DingTalk.Enabled && incoming.Channels.DingTalk.Enabled {
|
|
||||||
existing.Channels.DingTalk = incoming.Channels.DingTalk
|
|
||||||
}
|
|
||||||
if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled {
|
|
||||||
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing.Tools.Web.Brave.APIKey == "" {
|
|
||||||
existing.Tools.Web.Brave = incoming.Tools.Web.Brave
|
|
||||||
}
|
|
||||||
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
func camelToSnake(s string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for i, r := range s {
|
|
||||||
if unicode.IsUpper(r) {
|
|
||||||
if i > 0 {
|
|
||||||
prev := rune(s[i-1])
|
|
||||||
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
|
|
||||||
result.WriteRune('_')
|
|
||||||
} else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) {
|
|
||||||
result.WriteRune('_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.WriteRune(unicode.ToLower(r))
|
|
||||||
} else {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertKeysToSnake(data any) any {
|
|
||||||
switch v := data.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
result := make(map[string]any, len(v))
|
|
||||||
for key, val := range v {
|
|
||||||
result[camelToSnake(key)] = convertKeysToSnake(val)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case []any:
|
|
||||||
result := make([]any, len(v))
|
|
||||||
for i, val := range v {
|
|
||||||
result[i] = convertKeysToSnake(val)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
default:
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewriteWorkspacePath(path string) string {
|
|
||||||
path = strings.Replace(path, ".openclaw", ".picoclaw", 1)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMap(data map[string]any, key string) (map[string]any, bool) {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
m, ok := v.(map[string]any)
|
|
||||||
return m, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getString(data map[string]any, key string) (string, bool) {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
s, ok := v.(string)
|
|
||||||
return s, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFloat(data map[string]any, key string) (float64, bool) {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
f, ok := v.(float64)
|
|
||||||
return f, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBool(data map[string]any, key string) (bool, bool) {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
b, ok := v.(bool)
|
|
||||||
return b, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool {
|
|
||||||
if v, ok := getBool(data, key); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
func getStringSlice(data map[string]any, key string) []string {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
arr, ok := v.([]any)
|
|
||||||
if !ok {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
result := make([]string, 0, len(arr))
|
|
||||||
for _, item := range arr {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
result = append(result, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,50 @@
|
|||||||
package migrate
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
var migrateableFiles = []string{
|
func ResolveTargetHome(override string) (string, error) {
|
||||||
"AGENTS.md",
|
if override != "" {
|
||||||
"SOUL.md",
|
return ExpandHome(override), nil
|
||||||
"USER.md",
|
}
|
||||||
"TOOLS.md",
|
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
|
||||||
"HEARTBEAT.md",
|
return ExpandHome(envHome), nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolving home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var migrateableDirs = []string{
|
func ExpandHome(path string) string {
|
||||||
"memory",
|
if path == "" {
|
||||||
"skills",
|
return path
|
||||||
|
}
|
||||||
|
if path[0] == '~' {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if len(path) > 1 && path[1] == '/' {
|
||||||
|
return home + path[1:]
|
||||||
|
}
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
func PlanWorkspaceMigration(srcWorkspace, dstWorkspace string, force bool) ([]Action, error) {
|
func ResolveWorkspace(homeDir string) string {
|
||||||
|
return filepath.Join(homeDir, "workspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlanWorkspaceMigration(
|
||||||
|
srcWorkspace, dstWorkspace string,
|
||||||
|
migrateableFiles []string,
|
||||||
|
migrateableDirs []string,
|
||||||
|
force bool,
|
||||||
|
) ([]Action, error) {
|
||||||
var actions []Action
|
var actions []Action
|
||||||
|
|
||||||
for _, filename := range migrateableFiles {
|
for _, filename := range migrateableFiles {
|
||||||
@@ -50,7 +76,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
|||||||
return Action{
|
return Action{
|
||||||
Type: ActionSkip,
|
Type: ActionSkip,
|
||||||
Source: src,
|
Source: src,
|
||||||
Destination: dst,
|
Target: dst,
|
||||||
Description: "source file not found",
|
Description: "source file not found",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +86,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
|||||||
return Action{
|
return Action{
|
||||||
Type: ActionBackup,
|
Type: ActionBackup,
|
||||||
Source: src,
|
Source: src,
|
||||||
Destination: dst,
|
Target: dst,
|
||||||
Description: "destination exists, will backup and overwrite",
|
Description: "destination exists, will backup and overwrite",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +94,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
|||||||
return Action{
|
return Action{
|
||||||
Type: ActionCopy,
|
Type: ActionCopy,
|
||||||
Source: src,
|
Source: src,
|
||||||
Destination: dst,
|
Target: dst,
|
||||||
Description: "copy file",
|
Description: "copy file",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +117,7 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
|
|||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
actions = append(actions, Action{
|
actions = append(actions, Action{
|
||||||
Type: ActionCreateDir,
|
Type: ActionCreateDir,
|
||||||
Destination: dst,
|
Target: dst,
|
||||||
Description: "create directory",
|
Description: "create directory",
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
@@ -104,3 +130,33 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
|
|||||||
|
|
||||||
return actions, err
|
return actions, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RelPath(path, base string) string {
|
||||||
|
rel, err := filepath.Rel(base, path)
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Base(path)
|
||||||
|
}
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyFile(src, dst string) error {
|
||||||
|
srcFile, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srcFile.Close()
|
||||||
|
|
||||||
|
info, err := srcFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(dstFile, srcFile)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExpandHome(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{"/absolute/path", "/absolute/path"},
|
||||||
|
{"relative/path", "relative/path"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := ExpandHome(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandHomeWithTilde(t *testing.T) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result := ExpandHome("~/path")
|
||||||
|
assert.Equal(t, home+"/path", result)
|
||||||
|
|
||||||
|
result = ExpandHome("~")
|
||||||
|
assert.Equal(t, home, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWorkspace(t *testing.T) {
|
||||||
|
result := ResolveWorkspace("/home/user/.picoclaw")
|
||||||
|
assert.Equal(t, "/home/user/.picoclaw/workspace", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelPath(t *testing.T) {
|
||||||
|
result := RelPath("/home/user/.picoclaw/workspace/file.txt", "/home/user/.picoclaw")
|
||||||
|
assert.Equal(t, "workspace/file.txt", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelPathError(t *testing.T) {
|
||||||
|
result := RelPath("relative/path", "/different/base")
|
||||||
|
assert.Equal(t, "path", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTargetHome(t *testing.T) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := ResolveTargetHome("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join(home, ".picoclaw"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTargetHomeWithOverride(t *testing.T) {
|
||||||
|
result, err := ResolveTargetHome("/custom/path")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/custom/path", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
sourceFile := filepath.Join(tmpDir, "source.txt")
|
||||||
|
err := os.WriteFile(sourceFile, []byte("test content"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dstFile := filepath.Join(tmpDir, "dest.txt")
|
||||||
|
err = CopyFile(sourceFile, dstFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(dstFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "test content", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFileSourceNotFound(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
err := CopyFile(filepath.Join(tmpDir, "nonexistent.txt"), filepath.Join(tmpDir, "dest.txt"))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanWorkspaceMigration(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
|
||||||
|
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
|
||||||
|
|
||||||
|
err := os.MkdirAll(srcWorkspace, 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("content"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.MkdirAll(filepath.Join(srcWorkspace, "subdir"), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(srcWorkspace, "subdir", "file2.txt"), []byte("content"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
actions, err := PlanWorkspaceMigration(
|
||||||
|
srcWorkspace,
|
||||||
|
dstWorkspace,
|
||||||
|
[]string{"file1.txt"},
|
||||||
|
[]string{"subdir"},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, len(actions), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
|
||||||
|
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
|
||||||
|
|
||||||
|
err := os.MkdirAll(srcWorkspace, 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.MkdirAll(dstWorkspace, 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
actions, err := PlanWorkspaceMigration(
|
||||||
|
srcWorkspace,
|
||||||
|
dstWorkspace,
|
||||||
|
[]string{"file1.txt"},
|
||||||
|
[]string{},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.GreaterOrEqual(t, len(actions), 1)
|
||||||
|
assert.Equal(t, ActionBackup, actions[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanWorkspaceMigrationForce(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
|
||||||
|
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
|
||||||
|
|
||||||
|
err := os.MkdirAll(srcWorkspace, 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.MkdirAll(dstWorkspace, 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
actions, err := PlanWorkspaceMigration(
|
||||||
|
srcWorkspace,
|
||||||
|
dstWorkspace,
|
||||||
|
[]string{"file1.txt"},
|
||||||
|
[]string{},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.GreaterOrEqual(t, len(actions), 1)
|
||||||
|
assert.Equal(t, ActionCopy, actions[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
actions, err := PlanWorkspaceMigration(
|
||||||
|
filepath.Join(tmpDir, "nonexistent"),
|
||||||
|
filepath.Join(tmpDir, "dst", "workspace"),
|
||||||
|
[]string{"file1.txt"},
|
||||||
|
[]string{},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, actions, 1)
|
||||||
|
assert.Equal(t, ActionSkip, actions[0].Type)
|
||||||
|
assert.Contains(t, actions[0].Description, "source file not found")
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
DryRun bool
|
||||||
|
ConfigOnly bool
|
||||||
|
WorkspaceOnly bool
|
||||||
|
Force bool
|
||||||
|
Refresh bool
|
||||||
|
Source string
|
||||||
|
SourceHome string
|
||||||
|
TargetHome string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Operation interface {
|
||||||
|
GetSourceName() string
|
||||||
|
GetSourceHome() (string, error)
|
||||||
|
GetSourceWorkspace() (string, error)
|
||||||
|
GetSourceConfigFile() (string, error)
|
||||||
|
ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error
|
||||||
|
GetMigrateableFiles() []string
|
||||||
|
GetMigrateableDirs() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerFactory func(opts Options) Operation
|
||||||
|
|
||||||
|
type ActionType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionCopy ActionType = iota
|
||||||
|
ActionSkip
|
||||||
|
ActionBackup
|
||||||
|
ActionConvertConfig
|
||||||
|
ActionCreateDir
|
||||||
|
ActionMergeConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
type Action struct {
|
||||||
|
Type ActionType
|
||||||
|
Source string
|
||||||
|
Target string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
FilesCopied int
|
||||||
|
FilesSkipped int
|
||||||
|
BackupsCreated int
|
||||||
|
ConfigMigrated bool
|
||||||
|
DirsCreated int
|
||||||
|
Warnings []string
|
||||||
|
Errors []error
|
||||||
|
}
|
||||||
+136
-214
@@ -2,53 +2,73 @@ package migrate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/migrate/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/migrate/sources/openclaw"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ActionType int
|
type (
|
||||||
|
Options = internal.Options
|
||||||
|
Operation = internal.Operation
|
||||||
|
ActionType = internal.ActionType
|
||||||
|
Action = internal.Action
|
||||||
|
Result = internal.Result
|
||||||
|
HandlerFactory = internal.HandlerFactory
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ActionCopy ActionType = iota
|
ActionCopy = internal.ActionCopy
|
||||||
ActionSkip
|
ActionSkip = internal.ActionSkip
|
||||||
ActionBackup
|
ActionBackup = internal.ActionBackup
|
||||||
ActionConvertConfig
|
ActionConvertConfig = internal.ActionConvertConfig
|
||||||
ActionCreateDir
|
ActionCreateDir = internal.ActionCreateDir
|
||||||
ActionMergeConfig
|
ActionMergeConfig = internal.ActionMergeConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type MigrateInstance struct {
|
||||||
DryRun bool
|
options Options
|
||||||
ConfigOnly bool
|
handlers map[string]Operation
|
||||||
WorkspaceOnly bool
|
|
||||||
Force bool
|
|
||||||
Refresh bool
|
|
||||||
OpenClawHome string
|
|
||||||
PicoClawHome string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action struct {
|
func NewMigrateInstance(opts Options) *MigrateInstance {
|
||||||
Type ActionType
|
instance := &MigrateInstance{
|
||||||
Source string
|
options: opts,
|
||||||
Destination string
|
handlers: make(map[string]Operation),
|
||||||
Description string
|
}
|
||||||
|
|
||||||
|
openclaw_handler, err := openclaw.NewOpenclawHandler(opts)
|
||||||
|
if err == nil {
|
||||||
|
instance.Register(openclaw_handler.GetSourceName(), openclaw_handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
func (m *MigrateInstance) Register(moduleName string, module Operation) {
|
||||||
FilesCopied int
|
m.handlers[moduleName] = module
|
||||||
FilesSkipped int
|
|
||||||
BackupsCreated int
|
|
||||||
ConfigMigrated bool
|
|
||||||
DirsCreated int
|
|
||||||
Warnings []string
|
|
||||||
Errors []error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(opts Options) (*Result, error) {
|
func (m *MigrateInstance) getCurrentHandler() (Operation, error) {
|
||||||
|
source := m.options.Source
|
||||||
|
if source == "" {
|
||||||
|
source = "openclaw"
|
||||||
|
}
|
||||||
|
handler, ok := m.handlers[source]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Source '%s' not found", source)
|
||||||
|
}
|
||||||
|
return handler, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MigrateInstance) Run(opts Options) (*Result, error) {
|
||||||
|
handler, err := m.getCurrentHandler()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if opts.ConfigOnly && opts.WorkspaceOnly {
|
if opts.ConfigOnly && opts.WorkspaceOnly {
|
||||||
return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive")
|
return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive")
|
||||||
}
|
}
|
||||||
@@ -57,28 +77,28 @@ func Run(opts Options) (*Result, error) {
|
|||||||
opts.WorkspaceOnly = true
|
opts.WorkspaceOnly = true
|
||||||
}
|
}
|
||||||
|
|
||||||
openclawHome, err := resolveOpenClawHome(opts.OpenClawHome)
|
sourceHome, err := handler.GetSourceHome()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
picoClawHome, err := resolvePicoClawHome(opts.PicoClawHome)
|
targetHome, err := internal.ResolveTargetHome(opts.TargetHome)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
|
if _, err = os.Stat(sourceHome); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
|
return nil, fmt.Errorf("Source installation not found at %s", sourceHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
actions, warnings, err := Plan(opts, openclawHome, picoClawHome)
|
actions, warnings, err := m.Plan(opts, sourceHome, targetHome)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Migrating from OpenClaw to PicoClaw")
|
fmt.Println("Migrating from Source to PicoClaw")
|
||||||
fmt.Printf(" Source: %s\n", openclawHome)
|
fmt.Printf(" Source: %s\n", sourceHome)
|
||||||
fmt.Printf(" Destination: %s\n", picoClawHome)
|
fmt.Printf(" Target: %s\n", targetHome)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
if opts.DryRun {
|
if opts.DryRun {
|
||||||
@@ -95,19 +115,23 @@ func Run(opts Options) (*Result, error) {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
result := Execute(actions, openclawHome, picoClawHome)
|
result := m.Execute(actions, sourceHome, targetHome)
|
||||||
result.Warnings = warnings
|
result.Warnings = warnings
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string, error) {
|
func (m *MigrateInstance) Plan(opts Options, sourceHome, targetHome string) ([]Action, []string, error) {
|
||||||
var actions []Action
|
var actions []Action
|
||||||
var warnings []string
|
var warnings []string
|
||||||
|
handler, err := m.getCurrentHandler()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
force := opts.Force || opts.Refresh
|
force := opts.Force || opts.Refresh
|
||||||
|
|
||||||
if !opts.WorkspaceOnly {
|
if !opts.WorkspaceOnly {
|
||||||
configPath, err := findOpenClawConfig(openclawHome)
|
configPath, err := handler.GetSourceConfigFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if opts.ConfigOnly {
|
if opts.ConfigOnly {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -117,91 +141,95 @@ func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string,
|
|||||||
actions = append(actions, Action{
|
actions = append(actions, Action{
|
||||||
Type: ActionConvertConfig,
|
Type: ActionConvertConfig,
|
||||||
Source: configPath,
|
Source: configPath,
|
||||||
Destination: filepath.Join(picoClawHome, "config.json"),
|
Target: filepath.Join(targetHome, "config.json"),
|
||||||
Description: "convert OpenClaw config to PicoClaw format",
|
Description: "convert Source config to PicoClaw format",
|
||||||
})
|
})
|
||||||
|
|
||||||
data, err := LoadOpenClawConfig(configPath)
|
|
||||||
if err == nil {
|
|
||||||
_, configWarnings, _ := ConvertConfig(data)
|
|
||||||
warnings = append(warnings, configWarnings...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.ConfigOnly {
|
if !opts.ConfigOnly {
|
||||||
srcWorkspace := resolveWorkspace(openclawHome)
|
srcWorkspace, err := handler.GetSourceWorkspace()
|
||||||
dstWorkspace := resolveWorkspace(picoClawHome)
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("getting source workspace: %w", err)
|
||||||
|
}
|
||||||
|
dstWorkspace := internal.ResolveWorkspace(targetHome)
|
||||||
|
|
||||||
if _, err := os.Stat(srcWorkspace); err == nil {
|
if _, err := os.Stat(srcWorkspace); err == nil {
|
||||||
wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force)
|
wsActions, err := internal.PlanWorkspaceMigration(srcWorkspace, dstWorkspace,
|
||||||
|
handler.GetMigrateableFiles(),
|
||||||
|
handler.GetMigrateableDirs(),
|
||||||
|
force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("planning workspace migration: %w", err)
|
return nil, nil, fmt.Errorf("planning workspace migration: %w", err)
|
||||||
}
|
}
|
||||||
actions = append(actions, wsActions...)
|
actions = append(actions, wsActions...)
|
||||||
} else {
|
} else {
|
||||||
warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration")
|
warnings = append(warnings, "Source workspace directory not found, skipping workspace migration")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return actions, warnings, nil
|
return actions, warnings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
func (m *MigrateInstance) Execute(actions []Action, sourceHome, targetHome string) *Result {
|
||||||
result := &Result{}
|
result := &Result{}
|
||||||
|
handler, err := m.getCurrentHandler()
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case ActionConvertConfig:
|
case ActionConvertConfig:
|
||||||
if err := executeConfigMigration(action.Source, action.Destination, picoClawHome); err != nil {
|
if err := handler.ExecuteConfigMigration(action.Source, action.Target); err != nil {
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err))
|
result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err))
|
||||||
fmt.Printf(" ✗ Config migration failed: %v\n", err)
|
fmt.Printf(" ✗ Config migration failed: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
result.ConfigMigrated = true
|
result.ConfigMigrated = true
|
||||||
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
|
fmt.Printf(" ✓ Converted config: %s\n", action.Target)
|
||||||
}
|
}
|
||||||
case ActionCreateDir:
|
case ActionCreateDir:
|
||||||
if err := os.MkdirAll(action.Destination, 0o755); err != nil {
|
if err := os.MkdirAll(action.Target, 0o755); err != nil {
|
||||||
result.Errors = append(result.Errors, err)
|
result.Errors = append(result.Errors, err)
|
||||||
} else {
|
} else {
|
||||||
result.DirsCreated++
|
result.DirsCreated++
|
||||||
}
|
}
|
||||||
case ActionBackup:
|
case ActionBackup:
|
||||||
bakPath := action.Destination + ".bak"
|
bakPath := action.Target + ".bak"
|
||||||
if err := copyFile(action.Destination, bakPath); err != nil {
|
if err := internal.CopyFile(action.Target, bakPath); err != nil {
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err))
|
result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Target, err))
|
||||||
fmt.Printf(" ✗ Backup failed: %s\n", action.Destination)
|
fmt.Printf(" ✗ Backup failed: %s\n", action.Target)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.BackupsCreated++
|
result.BackupsCreated++
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
" ✓ Backed up %s -> %s.bak\n",
|
" ✓ Backed up %s -> %s.bak\n",
|
||||||
filepath.Base(action.Destination),
|
filepath.Base(action.Target),
|
||||||
filepath.Base(action.Destination),
|
filepath.Base(action.Target),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil {
|
||||||
result.Errors = append(result.Errors, err)
|
result.Errors = append(result.Errors, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := copyFile(action.Source, action.Destination); err != nil {
|
if err := internal.CopyFile(action.Source, action.Target); err != nil {
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
||||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
||||||
} else {
|
} else {
|
||||||
result.FilesCopied++
|
result.FilesCopied++
|
||||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome))
|
||||||
}
|
}
|
||||||
case ActionCopy:
|
case ActionCopy:
|
||||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil {
|
||||||
result.Errors = append(result.Errors, err)
|
result.Errors = append(result.Errors, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := copyFile(action.Source, action.Destination); err != nil {
|
if err := internal.CopyFile(action.Source, action.Target); err != nil {
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
||||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
||||||
} else {
|
} else {
|
||||||
result.FilesCopied++
|
result.FilesCopied++
|
||||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome))
|
||||||
}
|
}
|
||||||
case ActionSkip:
|
case ActionSkip:
|
||||||
result.FilesSkipped++
|
result.FilesSkipped++
|
||||||
@@ -211,31 +239,6 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) error {
|
|
||||||
data, err := LoadOpenClawConfig(srcConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
incoming, _, err := ConvertConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(dstConfigPath); err == nil {
|
|
||||||
existing, err := config.LoadConfig(dstConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("loading existing PicoClaw config: %w", err)
|
|
||||||
}
|
|
||||||
incoming = MergeConfig(existing, incoming)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return config.SaveConfig(dstConfigPath, incoming)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Confirm() bool {
|
func Confirm() bool {
|
||||||
fmt.Print("Proceed with migration? (y/n): ")
|
fmt.Print("Proceed with migration? (y/n): ")
|
||||||
var response string
|
var response string
|
||||||
@@ -243,49 +246,7 @@ func Confirm() bool {
|
|||||||
return strings.ToLower(strings.TrimSpace(response)) == "y"
|
return strings.ToLower(strings.TrimSpace(response)) == "y"
|
||||||
}
|
}
|
||||||
|
|
||||||
func PrintPlan(actions []Action, warnings []string) {
|
func (m *MigrateInstance) PrintSummary(result *Result) {
|
||||||
fmt.Println("Planned actions:")
|
|
||||||
copies := 0
|
|
||||||
skips := 0
|
|
||||||
backups := 0
|
|
||||||
configCount := 0
|
|
||||||
|
|
||||||
for _, action := range actions {
|
|
||||||
switch action.Type {
|
|
||||||
case ActionConvertConfig:
|
|
||||||
fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination)
|
|
||||||
configCount++
|
|
||||||
case ActionCopy:
|
|
||||||
fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
|
|
||||||
copies++
|
|
||||||
case ActionBackup:
|
|
||||||
fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination))
|
|
||||||
backups++
|
|
||||||
copies++
|
|
||||||
case ActionSkip:
|
|
||||||
if action.Description != "" {
|
|
||||||
fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
|
|
||||||
}
|
|
||||||
skips++
|
|
||||||
case ActionCreateDir:
|
|
||||||
fmt.Printf(" [mkdir] %s\n", action.Destination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(warnings) > 0 {
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Warnings:")
|
|
||||||
for _, w := range warnings {
|
|
||||||
fmt.Printf(" - %s\n", w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
|
|
||||||
copies, configCount, backups, skips)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrintSummary(result *Result) {
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
if result.FilesCopied > 0 {
|
if result.FilesCopied > 0 {
|
||||||
@@ -316,83 +277,44 @@ func PrintSummary(result *Result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveOpenClawHome(override string) (string, error) {
|
func PrintPlan(actions []Action, warnings []string) {
|
||||||
if override != "" {
|
fmt.Println("Planned actions:")
|
||||||
return expandHome(override), nil
|
copies := 0
|
||||||
}
|
skips := 0
|
||||||
if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
|
backups := 0
|
||||||
return expandHome(envHome), nil
|
configCount := 0
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".openclaw"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolvePicoClawHome(override string) (string, error) {
|
for _, action := range actions {
|
||||||
if override != "" {
|
switch action.Type {
|
||||||
return expandHome(override), nil
|
case ActionConvertConfig:
|
||||||
}
|
fmt.Printf(" [config] %s -> %s\n", action.Source, action.Target)
|
||||||
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
|
configCount++
|
||||||
return expandHome(envHome), nil
|
case ActionCopy:
|
||||||
}
|
fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
|
||||||
home, err := os.UserHomeDir()
|
copies++
|
||||||
if err != nil {
|
case ActionBackup:
|
||||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Target))
|
||||||
}
|
backups++
|
||||||
return filepath.Join(home, ".picoclaw"), nil
|
copies++
|
||||||
}
|
case ActionSkip:
|
||||||
|
if action.Description != "" {
|
||||||
func resolveWorkspace(homeDir string) string {
|
fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
|
||||||
return filepath.Join(homeDir, "workspace")
|
}
|
||||||
}
|
skips++
|
||||||
|
case ActionCreateDir:
|
||||||
func expandHome(path string) string {
|
fmt.Printf(" [mkdir] %s\n", action.Target)
|
||||||
if path == "" {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
if path[0] == '~' {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
if len(path) > 1 && path[1] == '/' {
|
|
||||||
return home + path[1:]
|
|
||||||
}
|
}
|
||||||
return home
|
|
||||||
}
|
}
|
||||||
return path
|
|
||||||
}
|
if len(warnings) > 0 {
|
||||||
|
fmt.Println()
|
||||||
func backupFile(path string) error {
|
fmt.Println("Warnings:")
|
||||||
bakPath := path + ".bak"
|
for _, w := range warnings {
|
||||||
return copyFile(path, bakPath)
|
fmt.Printf(" - %s\n", w)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
srcFile, err := os.Open(src)
|
fmt.Println()
|
||||||
if err != nil {
|
fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
|
||||||
return err
|
copies, configCount, backups, skips)
|
||||||
}
|
|
||||||
defer srcFile.Close()
|
|
||||||
|
|
||||||
info, err := srcFile.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer dstFile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(dstFile, srcFile)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func relPath(path, base string) string {
|
|
||||||
rel, err := filepath.Rel(base, path)
|
|
||||||
if err != nil {
|
|
||||||
return filepath.Base(path)
|
|
||||||
}
|
|
||||||
return rel
|
|
||||||
}
|
}
|
||||||
|
|||||||
+355
-819
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
package openclaw
|
||||||
|
|
||||||
|
var migrateableFiles = []string{
|
||||||
|
"AGENTS.md",
|
||||||
|
"SOUL.md",
|
||||||
|
"USER.md",
|
||||||
|
"TOOLS.md",
|
||||||
|
"HEARTBEAT.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrateableDirs = []string{
|
||||||
|
"memory",
|
||||||
|
"skills",
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportedChannels = map[string]bool{
|
||||||
|
"whatsapp": true,
|
||||||
|
"telegram": true,
|
||||||
|
"feishu": true,
|
||||||
|
"discord": true,
|
||||||
|
"maixcam": true,
|
||||||
|
"qq": true,
|
||||||
|
"dingtalk": true,
|
||||||
|
"slack": true,
|
||||||
|
"line": true,
|
||||||
|
"onebot": true,
|
||||||
|
"wecom": true,
|
||||||
|
"wecom_app": true,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,714 @@
|
|||||||
|
package openclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadOpenClawConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
|
||||||
|
testConfig := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": {
|
||||||
|
"primary": "anthropic/claude-sonnet-4-20250514"
|
||||||
|
},
|
||||||
|
"workspace": "~/.openclaw/workspace"
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"name": "Main Agent",
|
||||||
|
"model": {
|
||||||
|
"primary": "openai/gpt-4o",
|
||||||
|
"fallbacks": ["claude-3-opus"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"botToken": "test-token",
|
||||||
|
"allowFrom": ["user1", "user2"]
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "discord-token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"providers": {
|
||||||
|
"anthropic": {
|
||||||
|
"api_key": "sk-ant-test",
|
||||||
|
"base_url": "https://api.anthropic.com"
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"api_key": "sk-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadOpenClawConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Agents == nil {
|
||||||
|
t.Error("agents should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Agents.Defaults == nil {
|
||||||
|
t.Error("agents.defaults should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, model := cfg.GetDefaultModel()
|
||||||
|
if provider != "anthropic" {
|
||||||
|
t.Errorf("expected provider 'anthropic', got '%s'", provider)
|
||||||
|
}
|
||||||
|
if model != "claude-sonnet-4-20250514" {
|
||||||
|
t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", model)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := cfg.GetDefaultWorkspace()
|
||||||
|
if workspace != "~/.picoclaw/workspace" {
|
||||||
|
t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
agents := cfg.GetAgents()
|
||||||
|
if len(agents) != 1 {
|
||||||
|
t.Errorf("expected 1 agent, got %d", len(agents))
|
||||||
|
}
|
||||||
|
if agents[0].ID != "main" {
|
||||||
|
t.Errorf("expected agent id 'main', got '%s'", agents[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Channels == nil {
|
||||||
|
t.Error("channels should not be nil")
|
||||||
|
}
|
||||||
|
if cfg.Channels.Telegram == nil {
|
||||||
|
t.Error("telegram channel should not be nil")
|
||||||
|
}
|
||||||
|
if cfg.Channels.Telegram.BotToken == nil || *cfg.Channels.Telegram.BotToken != "test-token" {
|
||||||
|
t.Error("telegram bot token not parsed correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProviderConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
|
||||||
|
testConfig := `{
|
||||||
|
"models": {
|
||||||
|
"providers": {
|
||||||
|
"anthropic": {
|
||||||
|
"api_key": "sk-ant-test",
|
||||||
|
"base_url": "https://api.anthropic.com",
|
||||||
|
"max_tokens": 4096
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"api_key": "sk-test",
|
||||||
|
"base_url": "https://api.openai.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadOpenClawConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := GetProviderConfig(cfg.Models)
|
||||||
|
if len(providers) != 2 {
|
||||||
|
t.Errorf("expected 2 providers, got %d", len(providers))
|
||||||
|
}
|
||||||
|
|
||||||
|
if anthropic, ok := providers["anthropic"]; ok {
|
||||||
|
if anthropic.APIKey != "sk-ant-test" {
|
||||||
|
t.Errorf("expected anthropic api_key 'sk-ant-test', got '%s'", anthropic.APIKey)
|
||||||
|
}
|
||||||
|
if anthropic.BaseURL != "https://api.anthropic.com" {
|
||||||
|
t.Errorf("expected anthropic base_url 'https://api.anthropic.com', got '%s'", anthropic.BaseURL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("anthropic provider not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if openai, ok := providers["openai"]; ok {
|
||||||
|
if openai.APIKey != "sk-test" {
|
||||||
|
t.Errorf("expected openai api_key 'sk-test', got '%s'", openai.APIKey)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("openai provider not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToPicoClaw(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
|
||||||
|
testConfig := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": {
|
||||||
|
"primary": "anthropic/claude-sonnet-4-20250514"
|
||||||
|
},
|
||||||
|
"workspace": "~/.openclaw/workspace"
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"name": "Main Agent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "assistant",
|
||||||
|
"name": "Assistant",
|
||||||
|
"skills": ["skill1", "skill2"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"botToken": "test-token",
|
||||||
|
"allowFrom": ["user1", "user2"]
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "discord-token"
|
||||||
|
},
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"bridgeUrl": "http://localhost:3000"
|
||||||
|
},
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "app-id",
|
||||||
|
"appSecret": "app-secret",
|
||||||
|
"allowFrom": ["user3"]
|
||||||
|
},
|
||||||
|
"signal": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"providers": {
|
||||||
|
"anthropic": {
|
||||||
|
"api_key": "sk-ant-test"
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"api_key": "sk-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"entries": {
|
||||||
|
"skill1": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"memory": {"enabled": true},
|
||||||
|
"cron": {"enabled": true}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadOpenClawConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
picoCfg, warnings, err := cfg.ConvertToPicoClaw("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to convert config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if picoCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" {
|
||||||
|
t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", picoCfg.Agents.Defaults.ModelName)
|
||||||
|
}
|
||||||
|
if picoCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
|
||||||
|
t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", picoCfg.Agents.Defaults.Workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(picoCfg.Agents.List) != 2 {
|
||||||
|
t.Errorf("expected 2 agents, got %d", len(picoCfg.Agents.List))
|
||||||
|
}
|
||||||
|
if picoCfg.Agents.List[0].ID != "main" {
|
||||||
|
t.Errorf("expected first agent id 'main', got '%s'", picoCfg.Agents.List[0].ID)
|
||||||
|
}
|
||||||
|
if picoCfg.Agents.List[1].Skills == nil || len(picoCfg.Agents.List[1].Skills) != 2 {
|
||||||
|
t.Errorf("expected 2 skills for assistant agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !picoCfg.Channels.Telegram.Enabled {
|
||||||
|
t.Error("telegram should be enabled")
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.Telegram.Token != "test-token" {
|
||||||
|
t.Errorf("expected telegram token 'test-token', got '%s'", picoCfg.Channels.Telegram.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if picoCfg.Channels.WhatsApp.BridgeURL != "http://localhost:3000" {
|
||||||
|
t.Errorf("expected whatsapp bridge URL 'http://localhost:3000', got '%s'", picoCfg.Channels.WhatsApp.BridgeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if picoCfg.Channels.Feishu.AppID != "app-id" {
|
||||||
|
t.Errorf("expected feishu app ID 'app-id', got '%s'", picoCfg.Channels.Feishu.AppID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(picoCfg.ModelList) != 1 {
|
||||||
|
t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList))
|
||||||
|
}
|
||||||
|
|
||||||
|
foundWarning := false
|
||||||
|
for _, w := range warnings {
|
||||||
|
if len(w) > 0 {
|
||||||
|
foundWarning = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundWarning {
|
||||||
|
t.Log("warnings should be generated for skills, memory, cron, and unsupported channels")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
|
||||||
|
testConfig := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": {
|
||||||
|
"primary": "anthropic/claude-sonnet-4-20250514"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"qq": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "qq-app-id",
|
||||||
|
"appSecret": "qq-app-secret"
|
||||||
|
},
|
||||||
|
"dingtalk": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "ding-app-id",
|
||||||
|
"appSecret": "ding-app-secret"
|
||||||
|
},
|
||||||
|
"maixcam": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"port": 9000
|
||||||
|
},
|
||||||
|
"slack": {
|
||||||
|
"enabled": true,
|
||||||
|
"botToken": "xoxb-test",
|
||||||
|
"appToken": "xapp-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadOpenClawConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
picoCfg, _, err := cfg.ConvertToPicoClaw("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to convert config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !picoCfg.Channels.QQ.Enabled {
|
||||||
|
t.Error("qq should be enabled")
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.QQ.AppID != "qq-app-id" {
|
||||||
|
t.Errorf("expected qq app ID 'qq-app-id', got '%s'", picoCfg.Channels.QQ.AppID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !picoCfg.Channels.DingTalk.Enabled {
|
||||||
|
t.Error("dingtalk should be enabled")
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.DingTalk.ClientID != "ding-app-id" {
|
||||||
|
t.Errorf("expected dingtalk client ID 'ding-app-id', got '%s'", picoCfg.Channels.DingTalk.ClientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !picoCfg.Channels.MaixCam.Enabled {
|
||||||
|
t.Error("maixcam should be enabled")
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.MaixCam.Host != "192.168.1.100" {
|
||||||
|
t.Errorf("expected maixcam host '192.168.1.100', got '%s'", picoCfg.Channels.MaixCam.Host)
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.MaixCam.Port != 9000 {
|
||||||
|
t.Errorf("expected maixcam port 9000, got %d", picoCfg.Channels.MaixCam.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !picoCfg.Channels.Slack.Enabled {
|
||||||
|
t.Error("slack should be enabled")
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.Slack.BotToken != "xoxb-test" {
|
||||||
|
t.Errorf("expected slack bot token 'xoxb-test', got '%s'", picoCfg.Channels.Slack.BotToken)
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.Slack.AppToken != "xapp-test" {
|
||||||
|
t.Errorf("expected slack app token 'xapp-test', got '%s'", picoCfg.Channels.Slack.AppToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenClawAgentModel(t *testing.T) {
|
||||||
|
model := &OpenClawAgentModel{
|
||||||
|
Primary: strPtr("anthropic/claude-3-opus"),
|
||||||
|
Fallbacks: []string{"claude-3-sonnet", "claude-3-haiku"},
|
||||||
|
}
|
||||||
|
|
||||||
|
primary := model.GetPrimary()
|
||||||
|
if primary != "anthropic/claude-3-opus" {
|
||||||
|
t.Errorf("expected primary 'anthropic/claude-3-opus', got '%s'", primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbacks := model.GetFallbacks()
|
||||||
|
if len(fallbacks) != 2 {
|
||||||
|
t.Errorf("expected 2 fallbacks, got %d", len(fallbacks))
|
||||||
|
}
|
||||||
|
|
||||||
|
model2 := &OpenClawAgentModel{
|
||||||
|
Simple: "claude-3-opus",
|
||||||
|
}
|
||||||
|
|
||||||
|
primary2 := model2.GetPrimary()
|
||||||
|
if primary2 != "claude-3-opus" {
|
||||||
|
t.Errorf("expected primary 'claude-3-opus' from Simple, got '%s'", primary2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelEnabled(t *testing.T) {
|
||||||
|
cfg := &OpenClawConfig{
|
||||||
|
Channels: &OpenClawChannels{
|
||||||
|
Telegram: &OpenClawTelegramConfig{
|
||||||
|
Enabled: boolPtr(true),
|
||||||
|
},
|
||||||
|
Discord: &OpenClawDiscordConfig{
|
||||||
|
Enabled: boolPtr(false),
|
||||||
|
},
|
||||||
|
Slack: &OpenClawSlackConfig{
|
||||||
|
Enabled: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.IsChannelEnabled("telegram") {
|
||||||
|
t.Error("telegram should be enabled")
|
||||||
|
}
|
||||||
|
if cfg.IsChannelEnabled("discord") {
|
||||||
|
t.Error("discord should be disabled")
|
||||||
|
}
|
||||||
|
if !cfg.IsChannelEnabled("slack") {
|
||||||
|
t.Error("slack should be enabled (explicitly set)")
|
||||||
|
}
|
||||||
|
if cfg.IsChannelEnabled("line") {
|
||||||
|
t.Error("line should return false (not in switch cases)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDefaultModel(t *testing.T) {
|
||||||
|
cfg := &OpenClawConfig{
|
||||||
|
Agents: &OpenClawAgents{
|
||||||
|
Defaults: &OpenClawAgentDefaults{
|
||||||
|
Model: &OpenClawAgentModel{
|
||||||
|
Primary: strPtr("openai/gpt-4"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, model := cfg.GetDefaultModel()
|
||||||
|
if provider != "openai" {
|
||||||
|
t.Errorf("expected provider 'openai', got '%s'", provider)
|
||||||
|
}
|
||||||
|
if model != "gpt-4" {
|
||||||
|
t.Errorf("expected model 'gpt-4', got '%s'", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDefaultModelWithNoDefaults(t *testing.T) {
|
||||||
|
cfg := &OpenClawConfig{}
|
||||||
|
|
||||||
|
provider, model := cfg.GetDefaultModel()
|
||||||
|
if provider != "anthropic" {
|
||||||
|
t.Errorf("expected default provider 'anthropic', got '%s'", provider)
|
||||||
|
}
|
||||||
|
if model != "claude-sonnet-4-20250514" {
|
||||||
|
t.Errorf("expected default model 'claude-sonnet-4-20250514', got '%s'", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasFunctions(t *testing.T) {
|
||||||
|
cfg := &OpenClawConfig{
|
||||||
|
Skills: &OpenClawSkills{Entries: map[string]json.RawMessage{"skill1": nil}},
|
||||||
|
Memory: json.RawMessage(`{"enabled": true}`),
|
||||||
|
Cron: json.RawMessage(`{"enabled": true}`),
|
||||||
|
Hooks: json.RawMessage(`{"enabled": true}`),
|
||||||
|
Session: json.RawMessage(`{"enabled": true}`),
|
||||||
|
Auth: &OpenClawAuth{Profiles: json.RawMessage(`{"profile1": {}}`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.HasSkills() {
|
||||||
|
t.Error("should have skills")
|
||||||
|
}
|
||||||
|
if !cfg.HasMemory() {
|
||||||
|
t.Error("should have memory")
|
||||||
|
}
|
||||||
|
if !cfg.HasCron() {
|
||||||
|
t.Error("should have cron")
|
||||||
|
}
|
||||||
|
if !cfg.HasHooks() {
|
||||||
|
t.Error("should have hooks")
|
||||||
|
}
|
||||||
|
if !cfg.HasSession() {
|
||||||
|
t.Error("should have session")
|
||||||
|
}
|
||||||
|
if !cfg.HasAuthProfiles() {
|
||||||
|
t.Error("should have auth profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg2 := &OpenClawConfig{}
|
||||||
|
if cfg2.HasSkills() {
|
||||||
|
t.Error("should not have skills")
|
||||||
|
}
|
||||||
|
if cfg2.HasMemory() {
|
||||||
|
t.Error("should not have memory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOpenClawConfigFromDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
|
||||||
|
testConfig := `{"agents": {}}`
|
||||||
|
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadOpenClawConfigFromDir(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config from dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Agents == nil {
|
||||||
|
t.Error("agents should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = LoadOpenClawConfigFromDir("/nonexistent/dir")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("should return error for nonexistent dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToStandardConfig(t *testing.T) {
|
||||||
|
picoCfg := &PicoClawConfig{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "anthropic",
|
||||||
|
ModelName: "claude-sonnet-4-20250514",
|
||||||
|
Workspace: "~/.picoclaw/workspace",
|
||||||
|
},
|
||||||
|
List: []AgentConfig{
|
||||||
|
{
|
||||||
|
ID: "main",
|
||||||
|
Name: "Main Agent",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "claude-sonnet-4-20250514",
|
||||||
|
Model: "anthropic/claude-sonnet-4-20250514",
|
||||||
|
APIKey: "sk-ant-test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Channels: ChannelsConfig{
|
||||||
|
Telegram: TelegramConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test-token",
|
||||||
|
AllowFrom: []string{"user1"},
|
||||||
|
},
|
||||||
|
WhatsApp: WhatsAppConfig{
|
||||||
|
Enabled: true,
|
||||||
|
BridgeURL: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Gateway: GatewayConfig{
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
Port: 8080,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
stdCfg := picoCfg.ToStandardConfig()
|
||||||
|
|
||||||
|
if stdCfg.Agents.Defaults.Provider != "anthropic" {
|
||||||
|
t.Errorf("expected provider 'anthropic', got '%s'", stdCfg.Agents.Defaults.Provider)
|
||||||
|
}
|
||||||
|
if stdCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" {
|
||||||
|
t.Errorf("expected model name 'claude-sonnet-4-20250514', got '%s'", stdCfg.Agents.Defaults.ModelName)
|
||||||
|
}
|
||||||
|
if stdCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
|
||||||
|
t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", stdCfg.Agents.Defaults.Workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stdCfg.Agents.List) != 1 {
|
||||||
|
t.Errorf("expected 1 agent, got %d", len(stdCfg.Agents.List))
|
||||||
|
}
|
||||||
|
if stdCfg.Agents.List[0].ID != "main" {
|
||||||
|
t.Errorf("expected agent id 'main', got '%s'", stdCfg.Agents.List[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundModel := false
|
||||||
|
var foundAPIKey string
|
||||||
|
for _, m := range stdCfg.ModelList {
|
||||||
|
if m.ModelName == "claude-sonnet-4-20250514" {
|
||||||
|
foundModel = true
|
||||||
|
foundAPIKey = m.APIKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundModel {
|
||||||
|
t.Error("expected to find claude-sonnet-4-20250514 model config")
|
||||||
|
}
|
||||||
|
if foundAPIKey != "sk-ant-test" {
|
||||||
|
t.Errorf("expected api key 'sk-ant-test', got '%s'", foundAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stdCfg.Channels.Telegram.Enabled {
|
||||||
|
t.Error("telegram should be enabled")
|
||||||
|
}
|
||||||
|
if stdCfg.Channels.Telegram.Token != "test-token" {
|
||||||
|
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stdCfg.Gateway.Port != 8080 {
|
||||||
|
t.Errorf("expected gateway port 8080, got %d", stdCfg.Gateway.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadProviderConfigFromAgentsDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
agentsDir := filepath.Join(tmpDir, "agents", "main", "agent")
|
||||||
|
err := os.MkdirAll(agentsDir, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create agents dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsJSON := `{
|
||||||
|
"providers": {
|
||||||
|
"anthropic": {
|
||||||
|
"baseUrl": "https://api.anthropic.com",
|
||||||
|
"api": "anthropic",
|
||||||
|
"apiKey": "sk-ant-from-models",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "claude-sonnet-4-20250514",
|
||||||
|
"name": "Claude Sonnet 4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"baseUrl": "https://api.openai.com",
|
||||||
|
"api": "openai",
|
||||||
|
"apiKey": "sk-from-models",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "gpt-4o",
|
||||||
|
"name": "GPT-4o"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"zhipu": {
|
||||||
|
"baseUrl": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
"api": "openai",
|
||||||
|
"apiKey": "zhipu-key",
|
||||||
|
"models": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(agentsDir, "models.json"), []byte(modelsJSON), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write models.json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := GetProviderConfigFromDir(tmpDir)
|
||||||
|
if len(providers) != 3 {
|
||||||
|
t.Errorf("expected 3 providers, got %d", len(providers))
|
||||||
|
}
|
||||||
|
|
||||||
|
if anthropic, ok := providers["anthropic"]; ok {
|
||||||
|
if anthropic.ApiKey != "sk-ant-from-models" {
|
||||||
|
t.Errorf("expected anthropic apiKey 'sk-ant-from-models', got '%s'", anthropic.ApiKey)
|
||||||
|
}
|
||||||
|
if anthropic.BaseUrl != "https://api.anthropic.com" {
|
||||||
|
t.Errorf("expected anthropic baseUrl 'https://api.anthropic.com', got '%s'", anthropic.BaseUrl)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("anthropic provider not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if openai, ok := providers["openai"]; ok {
|
||||||
|
if openai.ApiKey != "sk-from-models" {
|
||||||
|
t.Errorf("expected openai apiKey 'sk-from-models', got '%s'", openai.ApiKey)
|
||||||
|
}
|
||||||
|
if openai.BaseUrl != "https://api.openai.com" {
|
||||||
|
t.Errorf("expected openai baseUrl 'https://api.openai.com', got '%s'", openai.BaseUrl)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("openai provider not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if zhipu, ok := providers["zhipu"]; ok {
|
||||||
|
if zhipu.ApiKey != "zhipu-key" {
|
||||||
|
t.Errorf("expected zhipu apiKey 'zhipu-key', got '%s'", zhipu.ApiKey)
|
||||||
|
}
|
||||||
|
if zhipu.BaseUrl != "https://open.bigmodel.cn/api/paas/v4" {
|
||||||
|
t.Errorf("expected zhipu baseUrl 'https://open.bigmodel.cn/api/paas/v4', got '%s'", zhipu.BaseUrl)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("zhipu provider not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProviderConfigFromDirNotExist(t *testing.T) {
|
||||||
|
providers := GetProviderConfigFromDir("/nonexistent/path")
|
||||||
|
if len(providers) != 0 {
|
||||||
|
t.Errorf("expected 0 providers for nonexistent path, got %d", len(providers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool {
|
||||||
|
return &b
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package openclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/migrate/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providerMapping = map[string]string{
|
||||||
|
"anthropic": "anthropic",
|
||||||
|
"claude": "anthropic",
|
||||||
|
"openai": "openai",
|
||||||
|
"gpt": "openai",
|
||||||
|
"groq": "groq",
|
||||||
|
"ollama": "ollama",
|
||||||
|
"openrouter": "openrouter",
|
||||||
|
"deepseek": "deepseek",
|
||||||
|
"together": "together",
|
||||||
|
"mistral": "mistral",
|
||||||
|
"fireworks": "fireworks",
|
||||||
|
"google": "google",
|
||||||
|
"gemini": "google",
|
||||||
|
"xai": "xai",
|
||||||
|
"grok": "xai",
|
||||||
|
"cerebras": "cerebras",
|
||||||
|
"sambanova": "sambanova",
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenclawHandler struct {
|
||||||
|
opts Options
|
||||||
|
sourceConfigFile string
|
||||||
|
sourceWorkspace string
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
Options = internal.Options
|
||||||
|
Action = internal.Action
|
||||||
|
Result = internal.Result
|
||||||
|
Operation = internal.Operation
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewOpenclawHandler(opts Options) (Operation, error) {
|
||||||
|
home, err := resolveSourceHome(opts.SourceHome)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opts.SourceHome = home
|
||||||
|
|
||||||
|
configFile, err := findSourceConfig(home)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &OpenclawHandler{
|
||||||
|
opts: opts,
|
||||||
|
sourceWorkspace: filepath.Join(opts.SourceHome, "workspace"),
|
||||||
|
sourceConfigFile: configFile,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenclawHandler) GetSourceName() string {
|
||||||
|
return "openclaw"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenclawHandler) GetSourceHome() (string, error) {
|
||||||
|
return o.opts.SourceHome, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenclawHandler) GetSourceWorkspace() (string, error) {
|
||||||
|
return o.sourceWorkspace, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenclawHandler) GetSourceConfigFile() (string, error) {
|
||||||
|
return o.sourceConfigFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenclawHandler) GetMigrateableFiles() []string {
|
||||||
|
return migrateableFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenclawHandler) GetMigrateableDirs() []string {
|
||||||
|
return migrateableDirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenclawHandler) ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error {
|
||||||
|
openclawCfg, err := LoadOpenClawConfig(srcConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
picoCfg, warnings, err := openclawCfg.ConvertToPicoClaw(o.opts.SourceHome)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range warnings {
|
||||||
|
fmt.Printf(" Warning: %s\n", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming := picoCfg.ToStandardConfig()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.SaveConfig(dstConfigPath, incoming)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSourceHome(override string) (string, error) {
|
||||||
|
if override != "" {
|
||||||
|
return internal.ExpandHome(override), nil
|
||||||
|
}
|
||||||
|
if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
|
||||||
|
return internal.ExpandHome(envHome), nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolving home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".openclaw"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSourceConfig(sourceHome string) (string, error) {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(sourceHome, "openclaw.json"),
|
||||||
|
filepath.Join(sourceHome, "config.json"),
|
||||||
|
}
|
||||||
|
for _, p := range candidates {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", sourceHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteWorkspacePath(path string) string {
|
||||||
|
path = strings.Replace(path, ".openclaw", ".picoclaw", 1)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapProvider(provider string) string {
|
||||||
|
if mapped, ok := providerMapping[strings.ToLower(provider)]; ok {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
return strings.ToLower(provider)
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package openclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewOpenclawHandler(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOpenclawHandlerNoConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawHandlerGetSourceName(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "openclaw", handler.GetSourceName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawHandlerGetSourceHome(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
home, err := handler.GetSourceHome()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tmpDir, home)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawHandlerGetSourceWorkspace(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
workspace, err := handler.GetSourceWorkspace()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join(tmpDir, "workspace"), workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawHandlerGetSourceConfigFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
configFile, err := handler.GetSourceConfigFile()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, configPath, configFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawHandlerGetSourceConfigFileWithConfigJson(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
configFile, err := handler.GetSourceConfigFile()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, configPath, configFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawHandlerGetMigrateableFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
files := handler.GetMigrateableFiles()
|
||||||
|
assert.NotEmpty(t, files)
|
||||||
|
assert.Contains(t, files, "AGENTS.md")
|
||||||
|
assert.Contains(t, files, "SOUL.md")
|
||||||
|
assert.Contains(t, files, "USER.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawHandlerGetMigrateableDirs(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler, err := NewOpenclawHandler(Options{
|
||||||
|
SourceHome: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dirs := handler.GetMigrateableDirs()
|
||||||
|
assert.NotEmpty(t, dirs)
|
||||||
|
assert.Contains(t, dirs, "memory")
|
||||||
|
assert.Contains(t, dirs, "skills")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSourceHome(t *testing.T) {
|
||||||
|
result, err := resolveSourceHome("/custom/path")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/custom/path", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSourceHomeWithEnvVar(t *testing.T) {
|
||||||
|
t.Setenv("OPENCLAW_HOME", "/env/path")
|
||||||
|
|
||||||
|
result, err := resolveSourceHome("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/env/path", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSourceHomeWithTilde(t *testing.T) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := resolveSourceHome("~/openclaw")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join(home, "openclaw"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSourceConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := findSourceConfig(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, configPath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSourceConfigWithConfigJson(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := findSourceConfig(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, configPath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSourceConfigNotFound(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := findSourceConfig(tmpDir)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no config file found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapProvider(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"anthropic", "anthropic"},
|
||||||
|
{"claude", "anthropic"},
|
||||||
|
{"openai", "openai"},
|
||||||
|
{"gpt", "openai"},
|
||||||
|
{"groq", "groq"},
|
||||||
|
{"ollama", "ollama"},
|
||||||
|
{"openrouter", "openrouter"},
|
||||||
|
{"deepseek", "deepseek"},
|
||||||
|
{"together", "together"},
|
||||||
|
{"mistral", "mistral"},
|
||||||
|
{"fireworks", "fireworks"},
|
||||||
|
{"google", "google"},
|
||||||
|
{"gemini", "google"},
|
||||||
|
{"xai", "xai"},
|
||||||
|
{"grok", "xai"},
|
||||||
|
{"cerebras", "cerebras"},
|
||||||
|
{"sambanova", "sambanova"},
|
||||||
|
{"unknown", "unknown"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := mapProvider(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result, "mapProvider(%q)", tt.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteWorkspacePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"~/.openclaw/workspace", "~/.picoclaw/workspace"},
|
||||||
|
{"/home/user/.openclaw/workspace", "/home/user/.picoclaw/workspace"},
|
||||||
|
{"/path/without/openclaw/change", "/path/without/openclaw/change"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := rewriteWorkspacePath(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result, "rewriteWorkspacePath(%q)", tt.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,11 +43,26 @@ func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
|
|||||||
|
|
||||||
// ResolveCandidates parses model config into a deduplicated candidate list.
|
// ResolveCandidates parses model config into a deduplicated candidate list.
|
||||||
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
|
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
|
||||||
|
return ResolveCandidatesWithLookup(cfg, defaultProvider, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveCandidatesWithLookup(
|
||||||
|
cfg ModelConfig,
|
||||||
|
defaultProvider string,
|
||||||
|
lookup func(raw string) (resolved string, ok bool),
|
||||||
|
) []FallbackCandidate {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var candidates []FallbackCandidate
|
var candidates []FallbackCandidate
|
||||||
|
|
||||||
addCandidate := func(raw string) {
|
addCandidate := func(raw string) {
|
||||||
ref := ParseModelRef(raw, defaultProvider)
|
candidateRaw := strings.TrimSpace(raw)
|
||||||
|
if lookup != nil {
|
||||||
|
if resolved, ok := lookup(candidateRaw); ok {
|
||||||
|
candidateRaw = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := ParseModelRef(candidateRaw, defaultProvider)
|
||||||
if ref == nil {
|
if ref == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,6 +453,75 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "step-3.5-flash",
|
||||||
|
Fallbacks: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "step-3.5-flash" {
|
||||||
|
return "openrouter/stepfun/step-3.5-flash:free", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
if candidates[0].Provider != "openrouter" {
|
||||||
|
t.Fatalf("provider = %q, want openrouter", candidates[0].Provider)
|
||||||
|
}
|
||||||
|
if candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
||||||
|
t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "step-3.5-flash",
|
||||||
|
Fallbacks: []string{"openrouter/stepfun/step-3.5-flash:free"},
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "step-3.5-flash" {
|
||||||
|
return "openrouter/stepfun/step-3.5-flash:free", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "glm-5",
|
||||||
|
Fallbacks: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "glm-5" {
|
||||||
|
return "glm-5", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "openai", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
if candidates[0].Provider != "openai" {
|
||||||
|
t.Fatalf("provider = %q, want openai", candidates[0].Provider)
|
||||||
|
}
|
||||||
|
if candidates[0].Model != "glm-5" {
|
||||||
|
t.Fatalf("model = %q, want glm-5", candidates[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFallbackExhaustedError_Message(t *testing.T) {
|
func TestFallbackExhaustedError_Message(t *testing.T) {
|
||||||
e := &FallbackExhaustedError{
|
e := &FallbackExhaustedError{
|
||||||
Attempts: []FallbackAttempt{
|
Attempts: []FallbackAttempt{
|
||||||
|
|||||||
+7
-3
@@ -33,15 +33,19 @@ type CronTool struct {
|
|||||||
func NewCronTool(
|
func NewCronTool(
|
||||||
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
||||||
execTimeout time.Duration, config *config.Config,
|
execTimeout time.Duration, config *config.Config,
|
||||||
) *CronTool {
|
) (*CronTool, error) {
|
||||||
execTool := NewExecToolWithConfig(workspace, restrict, config)
|
execTool, err := NewExecToolWithConfig(workspace, restrict, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to configure exec tool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
execTool.SetTimeout(execTimeout)
|
execTool.SetTimeout(execTimeout)
|
||||||
return &CronTool{
|
return &CronTool{
|
||||||
cronService: cronService,
|
cronService: cronService,
|
||||||
executor: executor,
|
executor: executor,
|
||||||
msgBus: msgBus,
|
msgBus: msgBus,
|
||||||
execTool: execTool,
|
execTool: execTool,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name returns the tool name
|
// Name returns the tool name
|
||||||
|
|||||||
+4
-5
@@ -69,11 +69,11 @@ var defaultDenyPatterns = []*regexp.Regexp{
|
|||||||
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExecTool(workingDir string, restrict bool) *ExecTool {
|
func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
|
||||||
return NewExecToolWithConfig(workingDir, restrict, nil)
|
return NewExecToolWithConfig(workingDir, restrict, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool {
|
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
|
||||||
denyPatterns := make([]*regexp.Regexp, 0)
|
denyPatterns := make([]*regexp.Regexp, 0)
|
||||||
|
|
||||||
if config != nil {
|
if config != nil {
|
||||||
@@ -86,8 +86,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
|
|||||||
for _, pattern := range execConfig.CustomDenyPatterns {
|
for _, pattern := range execConfig.CustomDenyPatterns {
|
||||||
re, err := regexp.Compile(pattern)
|
re, err := regexp.Compile(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err)
|
return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
denyPatterns = append(denyPatterns, re)
|
denyPatterns = append(denyPatterns, re)
|
||||||
}
|
}
|
||||||
@@ -106,7 +105,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
|
|||||||
denyPatterns: denyPatterns,
|
denyPatterns: denyPatterns,
|
||||||
allowPatterns: nil,
|
allowPatterns: nil,
|
||||||
restrictToWorkspace: restrict,
|
restrictToWorkspace: restrict,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) Name() string {
|
func (t *ExecTool) Name() string {
|
||||||
|
|||||||
+48
-11
@@ -11,7 +11,10 @@ import (
|
|||||||
|
|
||||||
// TestShellTool_Success verifies successful command execution
|
// TestShellTool_Success verifies successful command execution
|
||||||
func TestShellTool_Success(t *testing.T) {
|
func TestShellTool_Success(t *testing.T) {
|
||||||
tool := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
@@ -38,7 +41,10 @@ func TestShellTool_Success(t *testing.T) {
|
|||||||
|
|
||||||
// TestShellTool_Failure verifies failed command execution
|
// TestShellTool_Failure verifies failed command execution
|
||||||
func TestShellTool_Failure(t *testing.T) {
|
func TestShellTool_Failure(t *testing.T) {
|
||||||
tool := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
@@ -65,7 +71,11 @@ func TestShellTool_Failure(t *testing.T) {
|
|||||||
|
|
||||||
// TestShellTool_Timeout verifies command timeout handling
|
// TestShellTool_Timeout verifies command timeout handling
|
||||||
func TestShellTool_Timeout(t *testing.T) {
|
func TestShellTool_Timeout(t *testing.T) {
|
||||||
tool := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
tool.SetTimeout(100 * time.Millisecond)
|
tool.SetTimeout(100 * time.Millisecond)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -93,7 +103,10 @@ func TestShellTool_WorkingDir(t *testing.T) {
|
|||||||
testFile := filepath.Join(tmpDir, "test.txt")
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
os.WriteFile(testFile, []byte("test content"), 0o644)
|
os.WriteFile(testFile, []byte("test content"), 0o644)
|
||||||
|
|
||||||
tool := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
@@ -114,7 +127,10 @@ func TestShellTool_WorkingDir(t *testing.T) {
|
|||||||
|
|
||||||
// TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands
|
// TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands
|
||||||
func TestShellTool_DangerousCommand(t *testing.T) {
|
func TestShellTool_DangerousCommand(t *testing.T) {
|
||||||
tool := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
@@ -135,7 +151,10 @@ func TestShellTool_DangerousCommand(t *testing.T) {
|
|||||||
|
|
||||||
// TestShellTool_MissingCommand verifies error handling for missing command
|
// TestShellTool_MissingCommand verifies error handling for missing command
|
||||||
func TestShellTool_MissingCommand(t *testing.T) {
|
func TestShellTool_MissingCommand(t *testing.T) {
|
||||||
tool := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{}
|
args := map[string]any{}
|
||||||
@@ -150,7 +169,10 @@ func TestShellTool_MissingCommand(t *testing.T) {
|
|||||||
|
|
||||||
// TestShellTool_StderrCapture verifies stderr is captured and included
|
// TestShellTool_StderrCapture verifies stderr is captured and included
|
||||||
func TestShellTool_StderrCapture(t *testing.T) {
|
func TestShellTool_StderrCapture(t *testing.T) {
|
||||||
tool := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
@@ -170,7 +192,10 @@ func TestShellTool_StderrCapture(t *testing.T) {
|
|||||||
|
|
||||||
// TestShellTool_OutputTruncation verifies long output is truncated
|
// TestShellTool_OutputTruncation verifies long output is truncated
|
||||||
func TestShellTool_OutputTruncation(t *testing.T) {
|
func TestShellTool_OutputTruncation(t *testing.T) {
|
||||||
tool := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// Generate long output (>10000 chars)
|
// Generate long output (>10000 chars)
|
||||||
@@ -198,7 +223,11 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
|
|||||||
t.Fatalf("failed to create outside dir: %v", err)
|
t.Fatalf("failed to create outside dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tool := NewExecTool(workspace, true)
|
tool, err := NewExecTool(workspace, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
result := tool.Execute(context.Background(), map[string]any{
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
"command": "pwd",
|
"command": "pwd",
|
||||||
"working_dir": outsideDir,
|
"working_dir": outsideDir,
|
||||||
@@ -232,7 +261,11 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
|
|||||||
t.Skipf("symlinks not supported in this environment: %v", err)
|
t.Skipf("symlinks not supported in this environment: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tool := NewExecTool(workspace, true)
|
tool, err := NewExecTool(workspace, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
result := tool.Execute(context.Background(), map[string]any{
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
"command": "cat secret.txt",
|
"command": "cat secret.txt",
|
||||||
"working_dir": link,
|
"working_dir": link,
|
||||||
@@ -249,7 +282,11 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
|
|||||||
// TestShellTool_RestrictToWorkspace verifies workspace restriction
|
// TestShellTool_RestrictToWorkspace verifies workspace restriction
|
||||||
func TestShellTool_RestrictToWorkspace(t *testing.T) {
|
func TestShellTool_RestrictToWorkspace(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
tool := NewExecTool(tmpDir, false)
|
tool, err := NewExecTool(tmpDir, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
tool.SetRestrictToWorkspace(true)
|
tool.SetRestrictToWorkspace(true)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ func processExists(pid int) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
|
func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
|
||||||
tool := NewExecTool(t.TempDir(), false)
|
tool, err := NewExecTool(t.TempDir(), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
tool.SetTimeout(500 * time.Millisecond)
|
tool.SetTimeout(500 * time.Millisecond)
|
||||||
|
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
|
|||||||
Reference in New Issue
Block a user