This commit is contained in:
lpf
2026-02-13 14:11:59 +08:00
parent 085c265319
commit 28cea4c3bd
3 changed files with 283 additions and 11 deletions

View File

@@ -14,10 +14,12 @@ import (
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"syscall"
@@ -39,6 +41,7 @@ import (
const version = "0.1.0"
const logo = "🦞"
const gatewayServiceName = "clawgo-gateway.service"
var globalConfigPathOverride string
@@ -171,6 +174,8 @@ func main() {
}
case "version", "--version", "-v":
fmt.Printf("%s clawgo v%s\n", logo, version)
case "uninstall":
uninstallCmd()
default:
fmt.Printf("Unknown command: %s\n", command)
printHelp()
@@ -223,18 +228,29 @@ func printHelp() {
fmt.Println("Commands:")
fmt.Println(" onboard Initialize clawgo configuration and workspace")
fmt.Println(" agent Interact with the agent directly")
fmt.Println(" gateway Start clawgo gateway")
fmt.Println(" gateway Register/manage gateway service")
fmt.Println(" status Show clawgo status")
fmt.Println(" config Get/set config values")
fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" login Configure CLIProxyAPI upstream")
fmt.Println(" channel Test and manage messaging channels")
fmt.Println(" skills Manage skills (install, list, remove)")
fmt.Println(" uninstall Uninstall clawgo components")
fmt.Println(" version Show version information")
fmt.Println()
fmt.Println("Global options:")
fmt.Println(" --config <path> Use custom config file")
fmt.Println(" --debug, -d Enable debug logging")
fmt.Println()
fmt.Println("Gateway service:")
fmt.Println(" clawgo gateway # register service")
fmt.Println(" clawgo gateway start|stop|restart|status")
fmt.Println(" clawgo gateway run # run foreground")
fmt.Println()
fmt.Println("Uninstall:")
fmt.Println(" clawgo uninstall # remove gateway service")
fmt.Println(" clawgo uninstall --purge # also remove config/workspace dir")
fmt.Println(" clawgo uninstall --remove-bin # also remove current executable")
}
func onboard() {
@@ -604,14 +620,28 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
}
func gatewayCmd() {
// Check for --debug flag
args := os.Args[2:]
for _, arg := range args {
if arg == "--debug" || arg == "-d" {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
break
if len(args) == 0 {
if err := gatewayInstallServiceCmd(); err != nil {
fmt.Printf("Error registering gateway service: %v\n", err)
os.Exit(1)
}
return
}
switch args[0] {
case "run":
// continue to foreground runtime below
case "start", "stop", "restart", "status":
if err := gatewayServiceControlCmd(args[0]); err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
return
default:
fmt.Printf("Unknown gateway command: %s\n", args[0])
fmt.Println("Usage: clawgo gateway [run|start|stop|restart|status]")
return
}
cfg, err := loadConfig()
@@ -734,6 +764,141 @@ func gatewayCmd() {
}
}
func gatewayInstallServiceCmd() error {
scope, unitPath, err := detectGatewayServiceScopeAndPath()
if err != nil {
return err
}
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve executable path failed: %w", err)
}
exePath, _ = filepath.Abs(exePath)
configPath := getConfigPath()
workDir := filepath.Dir(exePath)
unitContent := buildGatewayUnitContent(scope, exePath, configPath, workDir)
if err := os.MkdirAll(filepath.Dir(unitPath), 0755); err != nil {
return fmt.Errorf("create service directory failed: %w", err)
}
if err := os.WriteFile(unitPath, []byte(unitContent), 0644); err != nil {
return fmt.Errorf("write service unit failed: %w", err)
}
if err := runSystemctl(scope, "daemon-reload"); err != nil {
return err
}
if err := runSystemctl(scope, "enable", gatewayServiceName); err != nil {
return err
}
fmt.Printf("✓ Gateway service registered: %s (%s)\n", gatewayServiceName, scope)
fmt.Printf(" Unit file: %s\n", unitPath)
fmt.Println(" Start service: clawgo gateway start")
fmt.Println(" Restart service: clawgo gateway restart")
fmt.Println(" Stop service: clawgo gateway stop")
return nil
}
func gatewayServiceControlCmd(action string) error {
scope, _, err := detectInstalledGatewayService()
if err != nil {
return err
}
return runSystemctl(scope, action, gatewayServiceName)
}
func detectGatewayServiceScopeAndPath() (string, string, error) {
// Linux-only systemd integration
if runtime.GOOS != "linux" {
return "", "", fmt.Errorf("gateway service registration currently supports Linux systemd only")
}
if strings.ToLower(strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_SCOPE"))) == "user" {
return userGatewayUnitPath()
}
if strings.ToLower(strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_SCOPE"))) == "system" {
return "system", "/etc/systemd/system/" + gatewayServiceName, nil
}
if os.Geteuid() == 0 {
return "system", "/etc/systemd/system/" + gatewayServiceName, nil
}
return userGatewayUnitPath()
}
func userGatewayUnitPath() (string, string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("resolve user home failed: %w", err)
}
return "user", filepath.Join(home, ".config", "systemd", "user", gatewayServiceName), nil
}
func detectInstalledGatewayService() (string, string, error) {
systemPath := "/etc/systemd/system/" + gatewayServiceName
if info, err := os.Stat(systemPath); err == nil && !info.IsDir() {
return "system", systemPath, nil
}
scope, userPath, err := userGatewayUnitPath()
if err != nil {
return "", "", err
}
if info, err := os.Stat(userPath); err == nil && !info.IsDir() {
return scope, userPath, nil
}
return "", "", fmt.Errorf("gateway service not registered. Run: clawgo gateway")
}
func buildGatewayUnitContent(scope, exePath, configPath, workDir string) string {
quotedExec := fmt.Sprintf("%q gateway run --config %q", exePath, configPath)
installTarget := "default.target"
if scope == "system" {
installTarget = "multi-user.target"
}
home, err := os.UserHomeDir()
if err != nil {
home = filepath.Dir(configPath)
}
return fmt.Sprintf(`[Unit]
Description=ClawGo Gateway
After=network.target
[Service]
Type=simple
WorkingDirectory=%s
ExecStart=%s
Restart=always
RestartSec=3
Environment=CLAWGO_CONFIG=%s
Environment=HOME=%s
[Install]
WantedBy=%s
`, workDir, quotedExec, configPath, home, installTarget)
}
func runSystemctl(scope string, args ...string) error {
cmdArgs := make([]string, 0, len(args)+1)
if scope == "user" {
cmdArgs = append(cmdArgs, "--user")
}
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("systemctl", cmdArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if scope == "user" {
return fmt.Errorf("systemctl --user %s failed: %w", strings.Join(args, " "), err)
}
return fmt.Errorf("systemctl %s failed: %w", strings.Join(args, " "), err)
}
return nil
}
func buildGatewayRuntime(ctx context.Context, cfg *config.Config, msgBus *bus.MessageBus, cronService *cron.CronService) (*agent.AgentLoop, *channels.Manager, error) {
provider, err := providers.CreateProvider(cfg)
if err != nil {
@@ -1796,3 +1961,73 @@ func channelTestCmd() {
fmt.Println("✓ Test message sent successfully!")
}
func uninstallCmd() {
purge := false
removeBin := false
for _, arg := range os.Args[2:] {
switch arg {
case "--purge":
purge = true
case "--remove-bin":
removeBin = true
}
}
// 1) Remove gateway service if registered.
if err := uninstallGatewayService(); err != nil {
fmt.Printf("Gateway service uninstall warning: %v\n", err)
} else {
fmt.Println("✓ Gateway service uninstalled")
}
// 2) Remove runtime pid file.
pidPath := filepath.Join(filepath.Dir(getConfigPath()), "gateway.pid")
_ = os.Remove(pidPath)
// 3) Optional purge config/workspace.
if purge {
configDir := filepath.Dir(getConfigPath())
if err := os.RemoveAll(configDir); err != nil {
fmt.Printf("Failed to remove config directory %s: %v\n", configDir, err)
os.Exit(1)
}
fmt.Printf("✓ Removed config/workspace directory: %s\n", configDir)
}
// 4) Optional remove current executable.
if removeBin {
exePath, err := os.Executable()
if err != nil {
fmt.Printf("Failed to resolve executable path: %v\n", err)
os.Exit(1)
}
if err := os.Remove(exePath); err != nil {
fmt.Printf("Failed to remove executable %s: %v\n", exePath, err)
os.Exit(1)
}
fmt.Printf("✓ Removed executable: %s\n", exePath)
}
}
func uninstallGatewayService() error {
scope, unitPath, err := detectInstalledGatewayService()
if err != nil {
// Service not present is not fatal for uninstall command.
return nil
}
// Ignore stop/disable errors to keep uninstall idempotent.
_ = runSystemctl(scope, "stop", gatewayServiceName)
_ = runSystemctl(scope, "disable", gatewayServiceName)
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove unit file failed: %w", err)
}
if err := runSystemctl(scope, "daemon-reload"); err != nil {
return err
}
return nil
}

View File

@@ -17,6 +17,7 @@ import (
"strconv"
"strings"
"syscall"
"time"
"clawgo/pkg/bus"
"clawgo/pkg/config"
@@ -251,19 +252,28 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
"tools_json": formatToolsForLog(providerToolDefs),
})
llmStart := time.Now()
response, err := al.callLLMWithModelFallback(ctx, messages, providerToolDefs, map[string]interface{}{
"max_tokens": 8192,
"temperature": 0.7,
})
llmElapsed := time.Since(llmStart)
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
map[string]interface{}{
"iteration": iteration,
"error": err.Error(),
"elapsed": llmElapsed.String(),
})
return "", fmt.Errorf("LLM call failed: %w", err)
}
logger.InfoCF("agent", "LLM call completed",
map[string]interface{}{
"iteration": iteration,
"elapsed": llmElapsed.String(),
"model": al.model,
})
if len(response.ToolCalls) == 0 {
finalContent = response.Content
@@ -458,19 +468,28 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
"tools_json": formatToolsForLog(providerToolDefs),
})
llmStart := time.Now()
response, err := al.callLLMWithModelFallback(ctx, messages, providerToolDefs, map[string]interface{}{
"max_tokens": 8192,
"temperature": 0.7,
})
llmElapsed := time.Since(llmStart)
if err != nil {
logger.ErrorCF("agent", "LLM call failed in system message",
map[string]interface{}{
"iteration": iteration,
"error": err.Error(),
"elapsed": llmElapsed.String(),
})
return "", fmt.Errorf("LLM call failed: %w", err)
}
logger.InfoCF("agent", "LLM call completed (system message)",
map[string]interface{}{
"iteration": iteration,
"elapsed": llmElapsed.String(),
"model": al.model,
})
if len(response.ToolCalls) == 0 {
finalContent = response.Content

View File

@@ -96,8 +96,8 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
log.Println("Stopping Telegram bot...")
c.setRunning(false)
// In telego v1.x, the long polling is stopped by canceling the context
// passed to UpdatesViaLongPolling. We don't need a separate Stop call
// In telego v1.x, the long polling is stopped by canceling the context
// passed to UpdatesViaLongPolling. We don't need a separate Stop call
// if we use the parent context correctly.
return nil
@@ -116,8 +116,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// Stop thinking animation
if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
log.Printf("Telegram thinking stop signal: chat_id=%s", msg.ChatID)
close(stop.(chan struct{}))
c.stopThinking.Delete(msg.ChatID)
} else {
log.Printf("Telegram thinking stop skipped: no stop channel found for chat_id=%s", msg.ChatID)
}
htmlContent := markdownToTelegramHTML(msg.Content)
@@ -125,6 +128,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// Try to edit placeholder
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
c.placeholders.Delete(msg.ChatID)
log.Printf("Telegram editing thinking placeholder: chat_id=%s message_id=%d", msg.ChatID, pID.(int))
_, err := c.bot.EditMessageText(ctx, &telego.EditMessageTextParams{
ChatID: chatID,
@@ -134,9 +138,13 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
})
if err == nil {
log.Printf("Telegram placeholder updated successfully: chat_id=%s", msg.ChatID)
return nil
}
log.Printf("Telegram placeholder update failed, fallback to new message: chat_id=%s err=%v", msg.ChatID, err)
// Fallback to new message if edit fails
} else {
log.Printf("Telegram placeholder not found, sending new message: chat_id=%s", msg.ChatID)
}
_, err = c.bot.SendMessage(ctx, telegoutil.Message(chatID, htmlContent).WithParseMode(telego.ModeHTML))
@@ -144,6 +152,9 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
if err != nil {
log.Printf("HTML parse failed, falling back to plain text: %v", err)
_, err = c.bot.SendMessage(ctx, telegoutil.Message(chatID, msg.Content))
if err != nil {
log.Printf("Telegram plain-text fallback send failed: chat_id=%s err=%v", msg.ChatID, err)
}
return err
}
@@ -259,11 +270,13 @@ func (c *TelegramChannel) handleMessage(message *telego.Message) {
stopChan := make(chan struct{})
c.stopThinking.Store(fmt.Sprintf("%d", chatID), stopChan)
log.Printf("Telegram thinking started: chat_id=%d", chatID)
pMsg, err := c.bot.SendMessage(context.Background(), telegoutil.Message(telegoutil.ID(chatID), "Thinking... 💭"))
if err == nil {
pID := pMsg.MessageID
c.placeholders.Store(fmt.Sprintf("%d", chatID), pID)
log.Printf("Telegram thinking placeholder created: chat_id=%d message_id=%d", chatID, pID)
go func(cid int64, mid int, stop <-chan struct{}) {
dots := []string{".", "..", "..."}
@@ -274,18 +287,23 @@ func (c *TelegramChannel) handleMessage(message *telego.Message) {
for {
select {
case <-stop:
log.Printf("Telegram thinking animation stopped: chat_id=%d", cid)
return
case <-ticker.C:
i++
text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)])
_, _ = c.bot.EditMessageText(context.Background(), &telego.EditMessageTextParams{
if _, err := c.bot.EditMessageText(context.Background(), &telego.EditMessageTextParams{
ChatID: telegoutil.ID(cid),
MessageID: mid,
Text: text,
})
}); err != nil {
log.Printf("Telegram thinking animation edit failed: chat_id=%d message_id=%d err=%v", cid, mid, err)
}
}
}
}(chatID, pID, stopChan)
} else {
log.Printf("Telegram thinking placeholder create failed: chat_id=%d err=%v", chatID, err)
}
metadata := map[string]string{