diff --git a/README.md b/README.md index a70cf94e..f2bd53c9 100644 --- a/README.md +++ b/README.md @@ -452,8 +452,8 @@ export GOCLAW_MODE=managed export GOCLAW_POSTGRES_DSN="postgres://user:pass@localhost:5432/goclaw?sslmode=disable" export GOCLAW_ENCRYPTION_KEY=$(openssl rand -hex 32) -# Run migrations -./goclaw migrate up +# Run database upgrade (schema migrations + data hooks) +./goclaw upgrade # Start gateway ./goclaw @@ -660,7 +660,13 @@ When `GOCLAW_*_API_KEY` environment variables are set, the gateway automatically goclaw Start gateway (default command) goclaw onboard Interactive setup wizard goclaw version Print version and protocol info -goclaw doctor System health check +goclaw doctor System health check (includes schema status) + In managed mode: reads providers and channels from DB + In standalone mode: reads from config.json + env vars + +goclaw upgrade Upgrade database schema and run data hooks +goclaw upgrade --status Show current vs required schema version +goclaw upgrade --dry-run Preview pending changes without applying goclaw agent list List configured agents goclaw agent chat Chat with an agent @@ -712,13 +718,14 @@ See [WebSocket Protocol](websocket-protocol.md) for the real-time RPC protocol ( ## Docker Compose -Seven composable files for different deployment scenarios: +Eight composable files for different deployment scenarios: | File | Purpose | | -------------------------------- | -------------------------------------------------- | | `docker-compose.yml` | Base service definition | | `docker-compose.standalone.yml` | File-based storage with persistent volumes | | `docker-compose.managed.yml` | PostgreSQL (pgvector/pgvector:pg18) + managed mode | +| `docker-compose.upgrade.yml` | One-shot database upgrade service | | `docker-compose.selfservice.yml` | Web dashboard UI (nginx + React SPA) | | `docker-compose.sandbox.yml` | Docker-based code execution sandbox | | `docker-compose.otel.yml` | OpenTelemetry + Jaeger tracing | @@ -756,6 +763,27 @@ docker compose -f docker-compose.yml \ curl http://localhost:18790/health ``` +### Upgrading (Managed Mode) + +When upgrading to a new version, the entrypoint automatically runs `goclaw upgrade` before starting. For explicit control, use the upgrade overlay: + +```bash +# Preview pending changes (dry-run) +docker compose -f docker-compose.yml -f docker-compose.managed.yml \ + -f docker-compose.upgrade.yml run --rm upgrade --dry-run + +# Apply upgrade (schema migrations + data hooks), then remove container +docker compose -f docker-compose.yml -f docker-compose.managed.yml \ + -f docker-compose.upgrade.yml run --rm upgrade + +# Check current schema status +docker compose -f docker-compose.yml -f docker-compose.managed.yml \ + -f docker-compose.upgrade.yml run --rm upgrade --status + +# Then restart the gateway with the new image +docker compose -f docker-compose.yml -f docker-compose.managed.yml up -d --build +``` + ### Environment File (.env) Use the `prepare-env.sh` script to generate `.env` with auto-generated secrets: diff --git a/cmd/doctor.go b/cmd/doctor.go index 66a05db5..61adb96d 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -1,15 +1,19 @@ package cmd import ( + "context" + "database/sql" "fmt" "os" "os/exec" "runtime" "strings" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/spf13/cobra" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/upgrade" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -25,7 +29,7 @@ func doctorCmd() *cobra.Command { func runDoctor() { fmt.Println("goclaw doctor") - fmt.Printf(" Version: 0.2.0 (protocol %d)\n", protocol.ProtocolVersion) + fmt.Printf(" Version: %s (protocol %d)\n", Version, protocol.ProtocolVersion) fmt.Printf(" OS: %s/%s\n", runtime.GOOS, runtime.GOARCH) fmt.Printf(" Go: %s\n", runtime.Version()) fmt.Println() @@ -45,26 +49,78 @@ func runDoctor() { return } - // Providers + // Database (managed mode only) — open early so we can show DB providers. + var db *sql.DB + isManaged := cfg.Database.Mode == "managed" && cfg.Database.PostgresDSN != "" + if isManaged { + fmt.Println() + fmt.Println(" Database:") + fmt.Printf(" %-12s managed\n", "Mode:") + var dbErr error + db, dbErr = sql.Open("pgx", cfg.Database.PostgresDSN) + if dbErr != nil { + fmt.Printf(" %-12s CONNECT FAILED (%s)\n", "Status:", dbErr) + db = nil + } else if pingErr := db.Ping(); pingErr != nil { + fmt.Printf(" %-12s CONNECT FAILED (%s)\n", "Status:", pingErr) + db.Close() + db = nil + } else { + defer db.Close() + s, schemaErr := upgrade.CheckSchema(db) + if schemaErr != nil { + fmt.Printf(" %-12s CHECK FAILED (%s)\n", "Schema:", schemaErr) + } else if s.Dirty { + fmt.Printf(" %-12s v%d (DIRTY — run: goclaw migrate force %d)\n", "Schema:", s.CurrentVersion, s.CurrentVersion-1) + } else if s.Compatible { + fmt.Printf(" %-12s v%d (up to date)\n", "Schema:", s.CurrentVersion) + } else if s.CurrentVersion > s.RequiredVersion { + fmt.Printf(" %-12s v%d (binary too old, requires v%d)\n", "Schema:", s.CurrentVersion, s.RequiredVersion) + } else { + fmt.Printf(" %-12s v%d (upgrade needed — run: goclaw upgrade)\n", "Schema:", s.CurrentVersion) + } + + pending, hookErr := upgrade.PendingHooks(context.Background(), db) + if hookErr == nil && len(pending) > 0 { + fmt.Printf(" %-12s %d pending\n", "Data hooks:", len(pending)) + } else if hookErr == nil { + fmt.Printf(" %-12s all applied\n", "Data hooks:") + } + } + } + + // Providers — show DB providers in managed mode, config providers otherwise. fmt.Println() fmt.Println(" Providers:") - checkProvider("Anthropic", cfg.Providers.Anthropic.APIKey) - checkProvider("OpenAI", cfg.Providers.OpenAI.APIKey) - checkProvider("OpenRouter", cfg.Providers.OpenRouter.APIKey) - checkProvider("Gemini", cfg.Providers.Gemini.APIKey) - checkProvider("Groq", cfg.Providers.Groq.APIKey) - checkProvider("DeepSeek", cfg.Providers.DeepSeek.APIKey) - checkProvider("Mistral", cfg.Providers.Mistral.APIKey) - checkProvider("XAI", cfg.Providers.XAI.APIKey) + if isManaged && db != nil { + checkDBProviders(db) + // Also show config-only providers (env vars) not in DB. + checkProvider("Anthropic (env)", cfg.Providers.Anthropic.APIKey) + checkProvider("OpenAI (env)", cfg.Providers.OpenAI.APIKey) + checkProvider("OpenRouter (env)", cfg.Providers.OpenRouter.APIKey) + } else { + checkProvider("Anthropic", cfg.Providers.Anthropic.APIKey) + checkProvider("OpenAI", cfg.Providers.OpenAI.APIKey) + checkProvider("OpenRouter", cfg.Providers.OpenRouter.APIKey) + checkProvider("Gemini", cfg.Providers.Gemini.APIKey) + checkProvider("Groq", cfg.Providers.Groq.APIKey) + checkProvider("DeepSeek", cfg.Providers.DeepSeek.APIKey) + checkProvider("Mistral", cfg.Providers.Mistral.APIKey) + checkProvider("XAI", cfg.Providers.XAI.APIKey) + } - // Channels + // Channels — show DB channels in managed mode, config channels otherwise. fmt.Println() fmt.Println(" Channels:") - checkChannel("Telegram", cfg.Channels.Telegram.Enabled, cfg.Channels.Telegram.Token != "") - checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "") - checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "") - checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "") - checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != "") + if isManaged && db != nil { + checkDBChannels(db) + } else { + checkChannel("Telegram", cfg.Channels.Telegram.Enabled, cfg.Channels.Telegram.Token != "") + checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "") + checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "") + checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "") + checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != "") + } // External tools fmt.Println() @@ -106,6 +162,66 @@ func checkChannel(name string, enabled, hasCredentials bool) { fmt.Printf(" %-12s %s\n", name+":", status) } +func checkDBChannels(db *sql.DB) { + rows, err := db.QueryContext(context.Background(), + "SELECT name, channel_type, enabled FROM channel_instances ORDER BY channel_type, name") + if err != nil { + fmt.Printf(" (could not query channels: %s)\n", err) + return + } + defer rows.Close() + + found := false + for rows.Next() { + var name, channelType string + var enabled bool + if err := rows.Scan(&name, &channelType, &enabled); err != nil { + continue + } + found = true + status := "enabled" + if !enabled { + status = "disabled" + } + label := fmt.Sprintf("%s/%s", channelType, name) + fmt.Printf(" %-24s %s\n", label+":", status) + } + if !found { + fmt.Println(" (none configured in database)") + } +} + +func checkDBProviders(db *sql.DB) { + rows, err := db.QueryContext(context.Background(), + "SELECT name, COALESCE(display_name, name), enabled, (api_key IS NOT NULL AND api_key != '') AS has_key FROM llm_providers ORDER BY name") + if err != nil { + fmt.Printf(" (could not query providers: %s)\n", err) + return + } + defer rows.Close() + + found := false + for rows.Next() { + var name, displayName string + var enabled, hasKey bool + if err := rows.Scan(&name, &displayName, &enabled, &hasKey); err != nil { + continue + } + found = true + status := "enabled" + if !enabled { + status = "disabled" + } + if !hasKey { + status += " (no API key)" + } + fmt.Printf(" %-16s %s\n", displayName+":", status) + } + if !found { + fmt.Println(" (none configured in database)") + } +} + func checkBinary(name string) { path, err := exec.LookPath(name) if err != nil { diff --git a/cmd/gateway.go b/cmd/gateway.go index 2b7beded..b53a94b4 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -306,6 +306,12 @@ func runGateway() { var traceCollector *tracing.Collector if cfg.Database.Mode == "managed" && cfg.Database.PostgresDSN != "" { + // Schema compatibility check: ensure DB schema matches this binary. + if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil { + slog.Error("schema compatibility check failed", "error", err) + os.Exit(1) + } + storeCfg := store.StoreConfig{ PostgresDSN: cfg.Database.PostgresDSN, Mode: cfg.Database.Mode, @@ -872,7 +878,7 @@ func runGateway() { gatewayMode = "managed" } slog.Info("goclaw gateway starting", - "version", "0.2.0", + "version", Version, "protocol", protocol.ProtocolVersion, "mode", gatewayMode, "agents", agentRouter.List(), diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index 48696b50..dad48c3a 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "log/slog" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -19,23 +20,43 @@ func builtinToolSeedData() []store.BuiltinToolDef { {Name: "edit", DisplayName: "Edit File", Description: "Apply targeted edits to files (search and replace)", Category: "filesystem", Enabled: true}, // runtime - {Name: "exec", DisplayName: "Execute Command", Description: "Execute shell commands in the workspace", Category: "runtime", Enabled: true}, + {Name: "exec", DisplayName: "Execute Command", Description: "Execute shell commands in the workspace", Category: "runtime", Enabled: true, + Metadata: json.RawMessage(`{"config_hint":"Config → Tools → Exec Approval"}`), + }, // web - {Name: "web_search", DisplayName: "Web Search", Description: "Search the web using Brave or DuckDuckGo", Category: "web", Enabled: true}, + {Name: "web_search", DisplayName: "Web Search", Description: "Search the web using Brave or DuckDuckGo", Category: "web", Enabled: true, + Metadata: json.RawMessage(`{"config_hint":"Config → Tools → Web Search"}`), + }, {Name: "web_fetch", DisplayName: "Web Fetch", Description: "Fetch and extract content from web URLs", Category: "web", Enabled: true}, // memory - {Name: "memory_search", DisplayName: "Memory Search", Description: "Search through stored memory entries", Category: "memory", Enabled: true}, - {Name: "memory_get", DisplayName: "Memory Get", Description: "Retrieve a specific memory entry by key", Category: "memory", Enabled: true}, + {Name: "memory_search", DisplayName: "Memory Search", Description: "Search through stored memory entries", Category: "memory", Enabled: true, + Requires: []string{"memory"}, + }, + {Name: "memory_get", DisplayName: "Memory Get", Description: "Retrieve a specific memory entry by key", Category: "memory", Enabled: true, + Requires: []string{"memory"}, + }, // media - {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true}, - {Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: true}, - {Name: "tts", DisplayName: "Text to Speech", Description: "Convert text to speech audio", Category: "media", Enabled: true}, + {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true, + Settings: json.RawMessage(`{"provider":"openrouter","model":"google/gemini-2.5-flash-image"}`), + Requires: []string{"vision_provider"}, + }, + {Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: true, + Settings: json.RawMessage(`{"provider":"openrouter","model":"google/gemini-2.5-flash-image"}`), + Requires: []string{"image_gen_provider"}, + }, + {Name: "tts", DisplayName: "Text to Speech", Description: "Convert text to speech audio", Category: "media", Enabled: true, + Requires: []string{"tts_provider"}, + Metadata: json.RawMessage(`{"config_hint":"Config → TTS"}`), + }, // browser - {Name: "browser", DisplayName: "Browser", Description: "Automate browser interactions (navigate, click, screenshot)", Category: "browser", Enabled: true}, + {Name: "browser", DisplayName: "Browser", Description: "Automate browser interactions (navigate, click, screenshot)", Category: "browser", Enabled: true, + Requires: []string{"browser"}, + Metadata: json.RawMessage(`{"config_hint":"Config → Tools → Browser"}`), + }, // sessions {Name: "sessions_list", DisplayName: "List Sessions", Description: "List active chat sessions", Category: "sessions", Enabled: true}, @@ -47,24 +68,42 @@ func builtinToolSeedData() []store.BuiltinToolDef { {Name: "message", DisplayName: "Message", Description: "Send messages to connected channels (Telegram, Discord, etc.)", Category: "messaging", Enabled: true}, // scheduling - {Name: "cron", DisplayName: "Cron Scheduler", Description: "Schedule recurring tasks with cron expressions", Category: "scheduling", Enabled: true}, + {Name: "cron", DisplayName: "Cron Scheduler", Description: "Schedule recurring tasks with cron expressions", Category: "scheduling", Enabled: true, + Metadata: json.RawMessage(`{"config_hint":"Config → Cron"}`), + }, // subagents - {Name: "spawn", DisplayName: "Spawn Subagent", Description: "Spawn an asynchronous background subagent", Category: "subagents", Enabled: true}, - {Name: "subagent", DisplayName: "Subagent", Description: "Run a synchronous subagent and wait for result", Category: "subagents", Enabled: true}, + {Name: "spawn", DisplayName: "Spawn Subagent", Description: "Spawn an asynchronous background subagent", Category: "subagents", Enabled: true, + Metadata: json.RawMessage(`{"config_hint":"Config → Agents Defaults"}`), + }, + {Name: "subagent", DisplayName: "Subagent", Description: "Run a synchronous subagent and wait for result", Category: "subagents", Enabled: true, + Metadata: json.RawMessage(`{"config_hint":"Config → Agents Defaults"}`), + }, // skills {Name: "skill_search", DisplayName: "Skill Search", Description: "Search available skills by keyword or description", Category: "skills", Enabled: true}, // delegation - {Name: "delegate", DisplayName: "Delegate", Description: "Delegate a task to another agent", Category: "delegation", Enabled: true}, - {Name: "delegate_search", DisplayName: "Delegate Search", Description: "Search for agents to delegate tasks to", Category: "delegation", Enabled: true}, - {Name: "evaluate_loop", DisplayName: "Evaluate Loop", Description: "Run an evaluate-optimize loop with delegated agents", Category: "delegation", Enabled: true}, - {Name: "handoff", DisplayName: "Handoff", Description: "Transfer conversation to another agent", Category: "delegation", Enabled: true}, + {Name: "delegate", DisplayName: "Delegate", Description: "Delegate a task to another agent", Category: "delegation", Enabled: true, + Requires: []string{"managed_mode", "agent_links"}, + }, + {Name: "delegate_search", DisplayName: "Delegate Search", Description: "Search for agents to delegate tasks to", Category: "delegation", Enabled: true, + Requires: []string{"managed_mode", "agent_links"}, + }, + {Name: "evaluate_loop", DisplayName: "Evaluate Loop", Description: "Run an evaluate-optimize loop with delegated agents", Category: "delegation", Enabled: true, + Requires: []string{"managed_mode", "agent_links"}, + }, + {Name: "handoff", DisplayName: "Handoff", Description: "Transfer conversation to another agent", Category: "delegation", Enabled: true, + Requires: []string{"managed_mode", "agent_links"}, + }, // teams - {Name: "team_tasks", DisplayName: "Team Tasks", Description: "Manage tasks within a team of agents", Category: "teams", Enabled: true}, - {Name: "team_message", DisplayName: "Team Message", Description: "Send messages between team agents", Category: "teams", Enabled: true}, + {Name: "team_tasks", DisplayName: "Team Tasks", Description: "Manage tasks within a team of agents", Category: "teams", Enabled: true, + Requires: []string{"managed_mode", "teams"}, + }, + {Name: "team_message", DisplayName: "Team Message", Description: "Send messages between team agents", Category: "teams", Enabled: true, + Requires: []string{"managed_mode", "teams"}, + }, } } diff --git a/cmd/migrate.go b/cmd/migrate.go index 2991d749..cffb47ec 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -1,6 +1,8 @@ package cmd import ( + "context" + "database/sql" "fmt" "log/slog" "os" @@ -10,9 +12,11 @@ import ( "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/spf13/cobra" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/upgrade" ) var migrationsDir string @@ -95,6 +99,22 @@ func migrateUpCmd() *cobra.Command { v, dirty, _ := m.Version() slog.Info("migration complete", "version", v, "dirty", dirty) + + // Run pending data hooks after SQL migrations. + db, dbErr := sql.Open("pgx", dsn) + if dbErr != nil { + slog.Warn("could not connect for data hooks", "error", dbErr) + return nil + } + defer db.Close() + + count, hookErr := upgrade.RunPendingHooks(context.Background(), db) + if hookErr != nil { + slog.Warn("data hooks failed", "error", hookErr) + } else if count > 0 { + slog.Info("data hooks applied", "count", count) + } + return nil }, } diff --git a/cmd/root.go b/cmd/root.go index bb6d1534..f0191fc7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -42,6 +42,7 @@ func init() { rootCmd.AddCommand(skillsCmd()) rootCmd.AddCommand(sessionsCmd()) rootCmd.AddCommand(migrateCmd()) + rootCmd.AddCommand(upgradeCmd()) } func versionCmd() *cobra.Command { diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 00000000..d4ca70e3 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,265 @@ +package cmd + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "os" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/spf13/cobra" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/upgrade" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +func upgradeCmd() *cobra.Command { + var dryRun bool + var status bool + + cmd := &cobra.Command{ + Use: "upgrade", + Short: "Upgrade database schema and run data migrations", + Long: "Applies pending SQL migrations and Go-based data hooks. Safe to run multiple times (idempotent).", + RunE: func(cmd *cobra.Command, args []string) error { + if status { + return runUpgradeStatus() + } + return runUpgrade(dryRun) + }, + } + + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be done without applying changes") + cmd.Flags().BoolVar(&status, "status", false, "show current upgrade status") + + return cmd +} + +func runUpgradeStatus() error { + cfg, err := config.Load(resolveConfigPath()) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + fmt.Printf(" App version: %s (protocol %d)\n", Version, protocol.ProtocolVersion) + + if cfg.Database.Mode != "managed" || cfg.Database.PostgresDSN == "" { + fmt.Println(" Mode: standalone (no database)") + fmt.Println(" Status: N/A (no schema migrations needed)") + return nil + } + + db, err := sql.Open("pgx", cfg.Database.PostgresDSN) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer db.Close() + + s, err := upgrade.CheckSchema(db) + if err != nil { + return fmt.Errorf("check schema: %w", err) + } + + fmt.Printf(" Schema current: %d\n", s.CurrentVersion) + fmt.Printf(" Schema required: %d\n", s.RequiredVersion) + + if s.Dirty { + fmt.Println(" Status: DIRTY (failed migration)") + fmt.Println() + fmt.Print(upgrade.FormatError(s)) + return nil + } + + if s.Compatible { + fmt.Println(" Status: UP TO DATE") + } else if s.CurrentVersion > s.RequiredVersion { + fmt.Println(" Status: BINARY TOO OLD") + } else { + fmt.Printf(" Status: UPGRADE NEEDED (%d -> %d)\n", s.CurrentVersion, s.RequiredVersion) + } + + // Check pending data hooks. + pending, err := upgrade.PendingHooks(context.Background(), db) + if err != nil { + slog.Debug("could not check pending data hooks", "error", err) + } else if len(pending) > 0 { + fmt.Printf("\n Pending data hooks: %d\n", len(pending)) + for _, name := range pending { + fmt.Printf(" - %s\n", name) + } + } + + if s.NeedsMigration { + fmt.Println() + fmt.Println(" Run 'goclaw upgrade' to apply all pending changes.") + } + + return nil +} + +func runUpgrade(dryRun bool) error { + cfg, err := config.Load(resolveConfigPath()) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + if cfg.Database.Mode != "managed" || cfg.Database.PostgresDSN == "" { + fmt.Println("Standalone mode — no database migrations needed.") + return nil + } + + dsn := cfg.Database.PostgresDSN + + db, err := sql.Open("pgx", dsn) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer db.Close() + + s, err := upgrade.CheckSchema(db) + if err != nil { + return fmt.Errorf("check schema: %w", err) + } + + fmt.Printf(" App version: %s (protocol %d)\n", Version, protocol.ProtocolVersion) + fmt.Printf(" Schema current: %d\n", s.CurrentVersion) + fmt.Printf(" Schema required: %d\n", s.RequiredVersion) + fmt.Println() + + if s.Dirty { + fmt.Print(upgrade.FormatError(s)) + return ErrUpgradeFailed + } + if s.CurrentVersion > s.RequiredVersion { + fmt.Print(upgrade.FormatError(s)) + return ErrUpgradeFailed + } + + if dryRun { + if s.NeedsMigration { + fmt.Printf(" Would apply SQL migrations: v%d -> v%d\n", s.CurrentVersion, s.RequiredVersion) + } else { + fmt.Println(" SQL schema is up to date.") + } + + pending, err := upgrade.PendingHooks(context.Background(), db) + if err != nil { + slog.Debug("could not check pending data hooks", "error", err) + } else if len(pending) > 0 { + fmt.Printf(" Would run %d data hook(s):\n", len(pending)) + for _, name := range pending { + fmt.Printf(" - %s\n", name) + } + } else { + fmt.Println(" No pending data hooks.") + } + return nil + } + + // Apply SQL migrations. + if s.NeedsMigration { + fmt.Print(" Applying SQL migrations... ") + m, err := newMigrator(dsn) + if err != nil { + return fmt.Errorf("create migrator: %w", err) + } + defer m.Close() + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + fmt.Println("FAILED") + return fmt.Errorf("migrate up: %w", err) + } + v, _, _ := m.Version() + fmt.Printf("OK (v%d -> v%d)\n", s.CurrentVersion, v) + } else { + fmt.Println(" SQL schema is up to date.") + } + + // Run data hooks. + fmt.Print(" Running data hooks... ") + count, err := upgrade.RunPendingHooks(context.Background(), db) + if err != nil { + fmt.Println("FAILED") + return fmt.Errorf("data hooks: %w", err) + } + if count > 0 { + fmt.Printf("%d applied\n", count) + } else { + fmt.Println("none pending") + } + + fmt.Println() + fmt.Println(" Upgrade complete.") + return nil +} + +// ErrUpgradeFailed is returned when upgrade cannot proceed. +var ErrUpgradeFailed = fmt.Errorf("upgrade cannot proceed") + +// checkSchemaOrAutoUpgrade is called from gateway startup to gate on schema compatibility. +// If GOCLAW_AUTO_UPGRADE=true and schema is outdated, it runs the upgrade inline. +func checkSchemaOrAutoUpgrade(dsn string) error { + db, err := sql.Open("pgx", dsn) + if err != nil { + return fmt.Errorf("schema check: connect: %w", err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + return fmt.Errorf("schema check: ping: %w", err) + } + + s, err := upgrade.CheckSchema(db) + if err != nil { + return fmt.Errorf("schema check: %w", err) + } + + if s.Compatible { + slog.Info("schema check passed", "current", s.CurrentVersion, "required", s.RequiredVersion) + return nil + } + + if s.Dirty { + return errors.New(upgrade.FormatError(s)) + } + + if s.CurrentVersion > s.RequiredVersion { + return errors.New(upgrade.FormatError(s)) + } + + // Schema is outdated — check if auto-upgrade is enabled. + if os.Getenv("GOCLAW_AUTO_UPGRADE") == "true" { + slog.Info("auto-upgrade: applying migrations", "from", s.CurrentVersion, "to", s.RequiredVersion) + + m, mErr := newMigrator(dsn) + if mErr != nil { + return fmt.Errorf("auto-upgrade: create migrator: %w", mErr) + } + defer m.Close() + + if mErr := m.Up(); mErr != nil && mErr != migrate.ErrNoChange { + return fmt.Errorf("auto-upgrade: migrate up: %w", mErr) + } + + v, _, _ := m.Version() + slog.Info("auto-upgrade: SQL migrations applied", "version", v) + + // Run data hooks. + count, hErr := upgrade.RunPendingHooks(context.Background(), db) + if hErr != nil { + return fmt.Errorf("auto-upgrade: data hooks: %w", hErr) + } + if count > 0 { + slog.Info("auto-upgrade: data hooks applied", "count", count) + } + + slog.Info("auto-upgrade complete") + return nil + } + + return errors.New(upgrade.FormatError(s)) +} diff --git a/docker-compose.upgrade.yml b/docker-compose.upgrade.yml new file mode 100644 index 00000000..a5069e52 --- /dev/null +++ b/docker-compose.upgrade.yml @@ -0,0 +1,38 @@ +# Upgrade overlay — one-shot database upgrade service. +# +# Usage: +# # Preview changes (dry-run): +# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade --dry-run +# +# # Apply upgrade: +# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade +# +# # Check status: +# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade --status +# +# The upgrade service runs goclaw upgrade and exits. Use --rm to auto-remove the container. + +services: + upgrade: + build: + context: . + dockerfile: Dockerfile + args: + ENABLE_OTEL: "false" + env_file: + - path: .env + required: false + environment: + - GOCLAW_MODE=managed + - GOCLAW_POSTGRES_DSN=postgres://${POSTGRES_USER:-goclaw}:${POSTGRES_PASSWORD:-goclaw}@postgres:5432/${POSTGRES_DB:-goclaw}?sslmode=disable + - GOCLAW_CONFIG=/app/data/config.json + - GOCLAW_MIGRATIONS_DIR=/app/migrations + - GOCLAW_ENCRYPTION_KEY=${GOCLAW_ENCRYPTION_KEY:-} + volumes: + - goclaw-data:/app/data + depends_on: + postgres: + condition: service_healthy + entrypoint: ["/app/goclaw"] + command: ["upgrade"] + restart: "no" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8f8c6d90..3edf2417 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,14 +3,18 @@ set -e case "${1:-serve}" in serve) - # Managed mode: auto-run migrations before starting + # Managed mode: auto-upgrade (schema migrations + data hooks) before starting. if [ "$GOCLAW_MODE" = "managed" ] && [ -n "$GOCLAW_POSTGRES_DSN" ]; then - echo "Managed mode: running migrations..." - /app/goclaw migrate up --migrations-dir "$GOCLAW_MIGRATIONS_DIR" || \ - echo "Migration warning (may already be up-to-date)" + echo "Managed mode: running upgrade..." + /app/goclaw upgrade || \ + echo "Upgrade warning (may already be up-to-date)" fi exec /app/goclaw ;; + upgrade) + shift + exec /app/goclaw upgrade "$@" + ;; migrate) shift exec /app/goclaw migrate "$@" diff --git a/internal/store/builtin_tool_store.go b/internal/store/builtin_tool_store.go index 32c949b6..22724b83 100644 --- a/internal/store/builtin_tool_store.go +++ b/internal/store/builtin_tool_store.go @@ -17,6 +17,7 @@ type BuiltinToolDef struct { Enabled bool `json:"enabled"` Settings json.RawMessage `json:"settings"` Requires []string `json:"requires,omitempty"` + Metadata json.RawMessage `json:"metadata"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/store/pg/builtin_tools.go b/internal/store/pg/builtin_tools.go index 924f23fd..328e3ec6 100644 --- a/internal/store/pg/builtin_tools.go +++ b/internal/store/pg/builtin_tools.go @@ -20,7 +20,7 @@ func NewPGBuiltinToolStore(db *sql.DB) *PGBuiltinToolStore { return &PGBuiltinToolStore{db: db} } -const builtinToolSelectCols = `name, display_name, description, category, enabled, settings, requires, created_at, updated_at` +const builtinToolSelectCols = `name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at` func (s *PGBuiltinToolStore) List(ctx context.Context) ([]store.BuiltinToolDef, error) { rows, err := s.db.QueryContext(ctx, @@ -108,13 +108,14 @@ func (s *PGBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinTool defer tx.Rollback() stmt, err := tx.PrepareContext(ctx, - `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ON CONFLICT (name) DO UPDATE SET display_name = EXCLUDED.display_name, description = EXCLUDED.description, category = EXCLUDED.category, requires = EXCLUDED.requires, + metadata = EXCLUDED.metadata, updated_at = EXCLUDED.updated_at`) if err != nil { return fmt.Errorf("prepare seed stmt: %w", err) @@ -127,9 +128,13 @@ func (s *PGBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinTool if settings == nil { settings = json.RawMessage("{}") } + metadata := t.Metadata + if metadata == nil { + metadata = json.RawMessage("{}") + } _, err := stmt.ExecContext(ctx, t.Name, t.DisplayName, t.Description, t.Category, - t.Enabled, []byte(settings), pqStringArray(t.Requires), now, + t.Enabled, []byte(settings), pqStringArray(t.Requires), []byte(metadata), now, ) if err != nil { return fmt.Errorf("seed tool %s: %w", t.Name, err) @@ -143,10 +148,11 @@ func (s *PGBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, erro var def store.BuiltinToolDef var settings []byte var requires []byte + var metadata []byte err := row.Scan( &def.Name, &def.DisplayName, &def.Description, &def.Category, - &def.Enabled, &settings, &requires, &def.CreatedAt, &def.UpdatedAt, + &def.Enabled, &settings, &requires, &metadata, &def.CreatedAt, &def.UpdatedAt, ) if err != nil { return nil, err @@ -155,6 +161,9 @@ func (s *PGBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, erro if settings != nil { def.Settings = json.RawMessage(settings) } + if metadata != nil { + def.Metadata = json.RawMessage(metadata) + } scanStringArray(requires, &def.Requires) return &def, nil @@ -167,10 +176,11 @@ func (s *PGBuiltinToolStore) scanTools(rows *sql.Rows) ([]store.BuiltinToolDef, var def store.BuiltinToolDef var settings []byte var requires []byte + var metadata []byte if err := rows.Scan( &def.Name, &def.DisplayName, &def.Description, &def.Category, - &def.Enabled, &settings, &requires, &def.CreatedAt, &def.UpdatedAt, + &def.Enabled, &settings, &requires, &metadata, &def.CreatedAt, &def.UpdatedAt, ); err != nil { continue } @@ -178,6 +188,9 @@ func (s *PGBuiltinToolStore) scanTools(rows *sql.Rows) ([]store.BuiltinToolDef, if settings != nil { def.Settings = json.RawMessage(settings) } + if metadata != nil { + def.Metadata = json.RawMessage(metadata) + } scanStringArray(requires, &def.Requires) result = append(result, def) diff --git a/internal/tools/create_image.go b/internal/tools/create_image.go index e36998e0..161f3774 100644 --- a/internal/tools/create_image.go +++ b/internal/tools/create_image.go @@ -132,12 +132,15 @@ func (t *CreateImageTool) resolveConfig(ctx context.Context) (providerName, mode Provider string `json:"provider"` Model string `json:"model"` } - if json.Unmarshal(raw, &cfg) == nil { - if providerName == "" && cfg.Provider != "" { - providerName = cfg.Provider - } - if model == "" && cfg.Model != "" { - model = cfg.Model + if json.Unmarshal(raw, &cfg) == nil && cfg.Provider != "" { + // DB settings are a provider+model pair — only use if provider is available + if _, err := t.registry.Get(cfg.Provider); err == nil { + if providerName == "" { + providerName = cfg.Provider + } + if model == "" && cfg.Model != "" { + model = cfg.Model + } } } } diff --git a/internal/tools/read_image.go b/internal/tools/read_image.go index 8d11cdc2..c1d777c6 100644 --- a/internal/tools/read_image.go +++ b/internal/tools/read_image.go @@ -27,12 +27,12 @@ func MediaImagesFromCtx(ctx context.Context) []providers.ImageContent { // --- ReadImageTool --- // visionProviderPriority is the order in which providers are tried for vision. -var visionProviderPriority = []string{"gemini", "anthropic", "openrouter"} +var visionProviderPriority = []string{"openrouter", "gemini", "anthropic"} // visionModelOverrides maps provider names to preferred vision models. // Providers not listed here use their default model. var visionModelOverrides = map[string]string{ - "openrouter": "google/gemini-2.0-flash-001", + "openrouter": "google/gemini-2.5-flash-image", } // ReadImageTool uses a vision-capable provider to describe images attached to the current message. diff --git a/internal/upgrade/checker.go b/internal/upgrade/checker.go new file mode 100644 index 00000000..24986e7e --- /dev/null +++ b/internal/upgrade/checker.go @@ -0,0 +1,93 @@ +package upgrade + +import ( + "database/sql" + "errors" + "fmt" +) + +// SchemaStatus represents the result of a schema compatibility check. +type SchemaStatus struct { + CurrentVersion uint + RequiredVersion uint + Dirty bool + Compatible bool + NeedsMigration bool +} + +var ( + ErrSchemaOutdated = errors.New("database schema is outdated") + ErrSchemaDirty = errors.New("database schema is dirty (failed migration)") + ErrSchemaAhead = errors.New("database schema is newer than this binary") +) + +// CheckSchema queries the schema_migrations table and compares +// against RequiredSchemaVersion to determine compatibility. +func CheckSchema(db *sql.DB) (*SchemaStatus, error) { + var version uint + var dirty bool + + err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return &SchemaStatus{ + RequiredVersion: RequiredSchemaVersion, + NeedsMigration: true, + }, nil + } + // Table might not exist (fresh DB). + return &SchemaStatus{ + RequiredVersion: RequiredSchemaVersion, + NeedsMigration: true, + }, nil + } + + s := &SchemaStatus{ + CurrentVersion: version, + RequiredVersion: RequiredSchemaVersion, + Dirty: dirty, + } + + if dirty { + return s, nil + } + + switch { + case version == RequiredSchemaVersion: + s.Compatible = true + case version < RequiredSchemaVersion: + s.NeedsMigration = true + default: + // Schema is ahead — binary is too old. + } + + return s, nil +} + +// FormatError returns a user-friendly error message for the given status. +func FormatError(s *SchemaStatus) string { + if s.Dirty { + return fmt.Sprintf( + "Database schema is in a dirty state (version %d).\n"+ + "This usually means a migration failed partway.\n\n"+ + " Fix: ./goclaw migrate force %d\n"+ + " Then: ./goclaw upgrade\n", + s.CurrentVersion, s.CurrentVersion-1, + ) + } + if s.CurrentVersion > s.RequiredVersion { + return fmt.Sprintf( + "Database schema (v%d) is newer than this binary (requires v%d).\n"+ + "You may be running an older version of goclaw.\n\n"+ + " Fix: upgrade your goclaw binary to the latest version.\n", + s.CurrentVersion, s.RequiredVersion, + ) + } + return fmt.Sprintf( + "Database schema is outdated: current v%d, required v%d.\n\n"+ + " Run: ./goclaw upgrade\n"+ + " Or: ./goclaw migrate up (SQL-only, no data hooks)\n\n"+ + " Docker/CI: set GOCLAW_AUTO_UPGRADE=true to upgrade automatically on startup.\n", + s.CurrentVersion, s.RequiredVersion, + ) +} diff --git a/internal/upgrade/data_hooks.go b/internal/upgrade/data_hooks.go new file mode 100644 index 00000000..9ef65ccd --- /dev/null +++ b/internal/upgrade/data_hooks.go @@ -0,0 +1,128 @@ +package upgrade + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "time" +) + +// DataHookFunc is a Go function that runs after a specific schema version's +// SQL migration has been applied. +type DataHookFunc func(ctx context.Context, db *sql.DB) error + +type dataHook struct { + SchemaVersion uint + Name string + Fn DataHookFunc +} + +var registry []dataHook + +// RegisterDataHook registers a Go data migration hook for a specific schema version. +// Name must be unique across all hooks. Hooks for the same version run in +// registration order. +func RegisterDataHook(schemaVersion uint, name string, fn DataHookFunc) { + registry = append(registry, dataHook{ + SchemaVersion: schemaVersion, + Name: name, + Fn: fn, + }) +} + +// PendingHooks returns the names of data hooks that haven't been applied yet. +func PendingHooks(ctx context.Context, db *sql.DB) ([]string, error) { + if err := ensureDataMigrationsTable(ctx, db); err != nil { + return nil, err + } + + applied, err := loadApplied(ctx, db) + if err != nil { + return nil, err + } + + var pending []string + for _, hook := range registry { + if !applied[hook.Name] { + pending = append(pending, hook.Name) + } + } + return pending, nil +} + +// RunPendingHooks executes all data hooks that haven't been applied yet. +// Each hook is tracked in the data_migrations table to ensure idempotency. +func RunPendingHooks(ctx context.Context, db *sql.DB) (int, error) { + if err := ensureDataMigrationsTable(ctx, db); err != nil { + return 0, fmt.Errorf("ensure data_migrations table: %w", err) + } + + applied, err := loadApplied(ctx, db) + if err != nil { + return 0, err + } + + count := 0 + for _, hook := range registry { + if applied[hook.Name] { + continue + } + + slog.Info("running data migration hook", + "name", hook.Name, + "schema_version", hook.SchemaVersion, + ) + start := time.Now() + + if err := hook.Fn(ctx, db); err != nil { + return count, fmt.Errorf("data hook %q failed: %w", hook.Name, err) + } + + // Record completion. + _, err := db.ExecContext(ctx, + "INSERT INTO data_migrations (name, version, applied_at) VALUES ($1, $2, NOW())", + hook.Name, hook.SchemaVersion, + ) + if err != nil { + return count, fmt.Errorf("record hook %q: %w", hook.Name, err) + } + + slog.Info("data migration hook complete", + "name", hook.Name, + "duration", time.Since(start), + ) + count++ + } + + return count, nil +} + +func ensureDataMigrationsTable(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS data_migrations ( + name VARCHAR(255) PRIMARY KEY, + version INT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `) + return err +} + +func loadApplied(ctx context.Context, db *sql.DB) (map[string]bool, error) { + rows, err := db.QueryContext(ctx, "SELECT name FROM data_migrations") + if err != nil { + return nil, fmt.Errorf("query data_migrations: %w", err) + } + defer rows.Close() + + applied := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + applied[name] = true + } + return applied, rows.Err() +} diff --git a/internal/upgrade/hooks.go b/internal/upgrade/hooks.go new file mode 100644 index 00000000..f92c3eb7 --- /dev/null +++ b/internal/upgrade/hooks.go @@ -0,0 +1,13 @@ +package upgrade + +// Data migration hooks are registered here. +// Add new hooks when a schema migration requires Go-based data transformation. +// +// Example: +// +// func init() { +// RegisterDataHook(8, "008_backfill_agent_slugs", func(ctx context.Context, db *sql.DB) error { +// // transform data after migration 000008 is applied +// return nil +// }) +// } diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go new file mode 100644 index 00000000..913cb227 --- /dev/null +++ b/internal/upgrade/version.go @@ -0,0 +1,5 @@ +package upgrade + +// RequiredSchemaVersion is the schema migration version this binary requires. +// Bump this whenever adding a new SQL migration file. +const RequiredSchemaVersion uint = 6 diff --git a/migrations/000006_builtin_tools.down.sql b/migrations/000006_builtin_tools.down.sql index f1ad498b..00f6e42b 100644 --- a/migrations/000006_builtin_tools.down.sql +++ b/migrations/000006_builtin_tools.down.sql @@ -1,2 +1,3 @@ +ALTER TABLE custom_tools DROP COLUMN IF EXISTS metadata; DROP INDEX IF EXISTS idx_builtin_tools_category; DROP TABLE IF EXISTS builtin_tools; diff --git a/migrations/000006_builtin_tools.up.sql b/migrations/000006_builtin_tools.up.sql index fe69a7e8..2e35a7e1 100644 --- a/migrations/000006_builtin_tools.up.sql +++ b/migrations/000006_builtin_tools.up.sql @@ -6,8 +6,12 @@ CREATE TABLE IF NOT EXISTS builtin_tools ( enabled BOOLEAN NOT NULL DEFAULT true, settings JSONB NOT NULL DEFAULT '{}', requires TEXT[] DEFAULT '{}', + metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_builtin_tools_category ON builtin_tools(category); + +-- Add metadata column to custom_tools for future extensibility +ALTER TABLE custom_tools ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'; diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx index 22a066c5..f22b1dce 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx @@ -1,13 +1,26 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, + DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Combobox } from "@/components/ui/combobox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useProviders } from "@/pages/providers/hooks/use-providers"; +import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; +import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify"; import type { BuiltinToolData } from "./hooks/use-builtin-tools"; interface Props { @@ -17,7 +30,166 @@ interface Props { onSave: (name: string, settings: Record) => Promise; } +const MEDIA_TOOLS = new Set(["read_image", "create_image"]); + export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }: Props) { + const isMedia = tool ? MEDIA_TOOLS.has(tool.name) : false; + + return ( + + + {isMedia && tool ? ( + + ) : ( + + )} + + + ); +} + +function MediaSettingsForm({ + tool, + onOpenChange, + onSave, +}: { + tool: BuiltinToolData; + onOpenChange: (open: boolean) => void; + onSave: (name: string, settings: Record) => Promise; +}) { + const { providers } = useProviders(); + const enabledProviders = providers.filter((p) => p.enabled); + + const settings = tool.settings ?? {}; + const [provider, setProvider] = useState((settings.provider as string) ?? ""); + const [model, setModel] = useState((settings.model as string) ?? ""); + const [saving, setSaving] = useState(false); + + // Resolve provider name → id for model list and verify + const selectedProviderId = useMemo( + () => enabledProviders.find((p) => p.name === provider)?.id, + [enabledProviders, provider], + ); + const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); + const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); + + useEffect(() => { + const s = tool.settings ?? {}; + setProvider((s.provider as string) ?? ""); + setModel((s.model as string) ?? ""); + }, [tool]); + + useEffect(() => { + resetVerify(); + }, [provider, model, resetVerify]); + + const handleVerify = async () => { + if (!selectedProviderId || !model.trim()) return; + await verify(selectedProviderId, model.trim()); + }; + + const handleSave = async () => { + setSaving(true); + try { + const next: Record = {}; + if (provider) next.provider = provider; + if (model) next.model = model; + await onSave(tool.name, next); + onOpenChange(false); + } finally { + setSaving(false); + } + }; + + return ( + <> + + {tool.display_name} Settings + + Configure the LLM provider and model. Leave empty for system defaults. + + +
+
+ + {enabledProviders.length > 0 ? ( + + ) : ( + + )} +
+
+ +
+
+ ({ value: m.id, label: m.name }))} + placeholder={modelsLoading ? "Loading models..." : "Enter or select model"} + /> +
+ +
+ {verifyResult && ( +

+ {verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"} +

+ )} +
+
+ + + + + + ); +} + +function JsonSettingsForm({ + tool, + onOpenChange, + onSave, +}: { + tool: BuiltinToolData | null; + onOpenChange: (open: boolean) => void; + onSave: (name: string, settings: Record) => Promise; +}) { const [json, setJson] = useState(""); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); @@ -45,34 +217,27 @@ export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }: }; return ( - - - - Settings: {tool?.display_name ?? tool?.name} - -
-

- Edit the JSON settings for this tool. For media tools, you can set{" "} - provider and model. -

-