feat: agents discovery
This commit is contained in:
@@ -869,6 +869,10 @@ Prior versions used a simpler JSON format (not JSON-RPC). For compatibility refe
|
||||
6. **Validate messages** - Check for required fields before processing
|
||||
7. **Set exit codes** - 0 for success, non-zero for errors
|
||||
|
||||
## Local Agent Discovery
|
||||
|
||||
Kandev checks a bundled discovery config to detect locally installed agents. Each agent entry lists OS-specific installation paths (Linux, Windows, macOS). At runtime, the backend selects the paths for the current OS, expands environment variables and `~`, and marks the agent as available if any configured file or directory exists.
|
||||
|
||||
### Message Flow
|
||||
|
||||
```
|
||||
|
||||
@@ -75,7 +75,14 @@ dev:
|
||||
@echo "Backend: http://localhost:8080"
|
||||
@echo "Web app: http://localhost:3000"
|
||||
@echo ""
|
||||
@$(MAKE) -C $(BACKEND_DIR) run & cd $(APPS_DIR) && $(PNPM) --filter @kandev/web dev
|
||||
@bash -c 'set -euo pipefail; \
|
||||
$(MAKE) -C $(BACKEND_DIR) run & backend_pid=$$!; \
|
||||
cd $(APPS_DIR); $(PNPM) --filter @kandev/web dev & web_pid=$$!; \
|
||||
cleanup() { kill $$backend_pid $$web_pid 2>/dev/null || true; }; \
|
||||
trap cleanup EXIT INT TERM; \
|
||||
wait -n $$backend_pid $$web_pid; status=$$?; \
|
||||
if [ $$status -eq 0 ]; then status=1; fi; \
|
||||
exit $$status'
|
||||
|
||||
.PHONY: dev-backend
|
||||
dev-backend:
|
||||
|
||||
@@ -29,10 +29,14 @@ import (
|
||||
// Agent Manager packages
|
||||
agentcontroller "github.com/kandev/kandev/internal/agent/controller"
|
||||
"github.com/kandev/kandev/internal/agent/credentials"
|
||||
"github.com/kandev/kandev/internal/agent/discovery"
|
||||
"github.com/kandev/kandev/internal/agent/docker"
|
||||
agenthandlers "github.com/kandev/kandev/internal/agent/handlers"
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
|
||||
agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers"
|
||||
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
||||
"github.com/kandev/kandev/internal/agent/worktree"
|
||||
|
||||
// Orchestrator packages
|
||||
@@ -130,6 +134,18 @@ func main() {
|
||||
defer taskRepo.Close()
|
||||
log.Info("SQLite database initialized", zap.String("db_path", dbPath))
|
||||
|
||||
agentSettingsRepo, err := settingsstore.NewSQLiteRepository(dbPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize agent settings store", zap.Error(err), zap.String("db_path", dbPath))
|
||||
}
|
||||
defer agentSettingsRepo.Close()
|
||||
|
||||
discoveryRegistry, err := discovery.LoadRegistry()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load agent discovery config", zap.Error(err))
|
||||
}
|
||||
agentSettingsController := agentsettingscontroller.NewController(agentSettingsRepo, discoveryRegistry, log)
|
||||
|
||||
taskSvc := taskservice.NewService(
|
||||
taskRepo,
|
||||
eventBus,
|
||||
@@ -514,6 +530,9 @@ func main() {
|
||||
taskhandlers.RegisterCommentRoutes(router, gateway.Dispatcher, taskSvc, &orchestratorAdapter{svc: orchestratorSvc}, log)
|
||||
log.Info("Registered Task Service handlers (HTTP + WebSocket)")
|
||||
|
||||
agentsettingshandlers.RegisterRoutes(router, agentSettingsController, gateway.Hub, log)
|
||||
log.Info("Registered Agent Settings handlers (HTTP)")
|
||||
|
||||
// Health check (simple HTTP for load balancers/monitoring)
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "claude",
|
||||
"supports_mcp": false,
|
||||
"mcp_config_path": {
|
||||
"linux": [],
|
||||
"windows": [],
|
||||
"macos": []
|
||||
},
|
||||
"installation_path": {
|
||||
"linux": ["~/.claude.json"],
|
||||
"windows": ["~/.claude.json"],
|
||||
"macos": ["~/.claude.json"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gemini",
|
||||
"supports_mcp": false,
|
||||
"mcp_config_path": {
|
||||
"linux": [],
|
||||
"windows": [],
|
||||
"macos": []
|
||||
},
|
||||
"installation_path": {
|
||||
"linux": ["~/.gemini/oauth_creds.json"],
|
||||
"windows": ["~/.gemini/oauth_creds.json"],
|
||||
"macos": ["~/.gemini/oauth_creds.json"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "codex",
|
||||
"supports_mcp": false,
|
||||
"mcp_config_path": {
|
||||
"linux": [],
|
||||
"windows": [],
|
||||
"macos": []
|
||||
},
|
||||
"installation_path": {
|
||||
"linux": ["~/.codex/auth.json"],
|
||||
"windows": ["~/.codex/auth.json"],
|
||||
"macos": ["~/.codex/auth.json"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auggie",
|
||||
"supports_mcp": false,
|
||||
"mcp_config_path": {
|
||||
"linux": [],
|
||||
"windows": [],
|
||||
"macos": []
|
||||
},
|
||||
"installation_path": {
|
||||
"linux": ["~/.augment/.auggie.json"],
|
||||
"windows": ["~/.augment/.auggie.json"],
|
||||
"macos": ["~/.augment/.auggie.json"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "opencode",
|
||||
"supports_mcp": false,
|
||||
"mcp_config_path": {
|
||||
"linux": [],
|
||||
"windows": [],
|
||||
"macos": []
|
||||
},
|
||||
"installation_path": {
|
||||
"linux": [
|
||||
"$XDG_CONFIG_HOME/opencode/opencode.json",
|
||||
"~/.config/opencode/opencode.json",
|
||||
"$XDG_CONFIG_HOME/opencode",
|
||||
"~/.config/opencode"
|
||||
],
|
||||
"windows": [
|
||||
"~/AppData/Roaming/opencode/opencode.json",
|
||||
"~/AppData/Roaming/opencode"
|
||||
],
|
||||
"macos": [
|
||||
"~/Library/Application Support/opencode/opencode.json",
|
||||
"~/Library/Application Support/opencode"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "copilot",
|
||||
"supports_mcp": true,
|
||||
"mcp_config_path": {
|
||||
"linux": ["~/.copilot/mcp-config.json"],
|
||||
"windows": ["~/.copilot/mcp-config.json"],
|
||||
"macos": ["~/.copilot/mcp-config.json"]
|
||||
},
|
||||
"installation_path": {
|
||||
"linux": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"],
|
||||
"windows": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"],
|
||||
"macos": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed agents.json
|
||||
var agentsFS embed.FS
|
||||
|
||||
type OSPaths struct {
|
||||
Linux []string `json:"linux"`
|
||||
Windows []string `json:"windows"`
|
||||
MacOS []string `json:"macos"`
|
||||
}
|
||||
|
||||
func (p OSPaths) ForOS(goos string) []string {
|
||||
switch goos {
|
||||
case "windows":
|
||||
return p.Windows
|
||||
case "darwin":
|
||||
return p.MacOS
|
||||
default:
|
||||
return p.Linux
|
||||
}
|
||||
}
|
||||
|
||||
type KnownAgent struct {
|
||||
Name string `json:"name"`
|
||||
SupportsMCP bool `json:"supports_mcp"`
|
||||
MCPConfigPath OSPaths `json:"mcp_config_path"`
|
||||
InstallationPath OSPaths `json:"installation_path"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Agents []KnownAgent `json:"agents"`
|
||||
}
|
||||
|
||||
type Availability struct {
|
||||
Name string `json:"name"`
|
||||
SupportsMCP bool `json:"supports_mcp"`
|
||||
MCPConfigPath string `json:"mcp_config_path,omitempty"`
|
||||
InstallationPaths []string `json:"installation_paths,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
MatchedPath string `json:"matched_path,omitempty"`
|
||||
}
|
||||
|
||||
type Adapter interface {
|
||||
Detect(ctx context.Context) (Availability, error)
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
adapters []Adapter
|
||||
}
|
||||
|
||||
func LoadRegistry() (*Registry, error) {
|
||||
data, err := agentsFS.ReadFile("agents.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read agents config: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse agents config: %w", err)
|
||||
}
|
||||
adapters := make([]Adapter, 0, len(cfg.Agents))
|
||||
for _, agent := range cfg.Agents {
|
||||
adapters = append(adapters, NewFilePresenceAdapter(agent))
|
||||
}
|
||||
return &Registry{adapters: adapters}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Detect(ctx context.Context) ([]Availability, error) {
|
||||
results := make([]Availability, 0, len(r.adapters))
|
||||
for _, adapter := range r.adapters {
|
||||
availability, err := adapter.Detect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, availability)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type FilePresenceAdapter struct {
|
||||
definition KnownAgent
|
||||
}
|
||||
|
||||
func NewFilePresenceAdapter(def KnownAgent) *FilePresenceAdapter {
|
||||
return &FilePresenceAdapter{definition: def}
|
||||
}
|
||||
|
||||
func (a *FilePresenceAdapter) Detect(ctx context.Context) (Availability, error) {
|
||||
_ = ctx
|
||||
paths := resolvePaths(a.definition.InstallationPath.ForOS(runtime.GOOS))
|
||||
mcpPaths := resolvePaths(a.definition.MCPConfigPath.ForOS(runtime.GOOS))
|
||||
available, matched := anyPathExists(paths)
|
||||
mcpPath := ""
|
||||
if len(mcpPaths) > 0 {
|
||||
mcpPath = mcpPaths[0]
|
||||
}
|
||||
return Availability{
|
||||
Name: a.definition.Name,
|
||||
SupportsMCP: a.definition.SupportsMCP,
|
||||
MCPConfigPath: mcpPath,
|
||||
InstallationPaths: paths,
|
||||
Available: available,
|
||||
MatchedPath: matched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolvePaths(paths []string) []string {
|
||||
resolved := make([]string, 0, len(paths))
|
||||
for _, rawPath := range paths {
|
||||
expanded := expandPath(rawPath)
|
||||
if expanded == "" {
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, expanded)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func expandPath(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(path, "$XDG_CONFIG_HOME") || strings.Contains(path, "${XDG_CONFIG_HOME}") {
|
||||
if _, ok := os.LookupEnv("XDG_CONFIG_HOME"); !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
expanded := os.ExpandEnv(path)
|
||||
if strings.HasPrefix(expanded, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
expanded = filepath.Join(home, strings.TrimPrefix(expanded, "~"))
|
||||
}
|
||||
}
|
||||
if expanded == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(filepath.FromSlash(expanded))
|
||||
}
|
||||
|
||||
func anyPathExists(paths []string) (bool, string) {
|
||||
for _, candidate := range paths {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return true, candidate
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/discovery"
|
||||
"github.com/kandev/kandev/internal/agent/settings/dto"
|
||||
"github.com/kandev/kandev/internal/agent/settings/models"
|
||||
"github.com/kandev/kandev/internal/agent/settings/store"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAgentNotFound = errors.New("agent not found")
|
||||
ErrAgentProfileNotFound = errors.New("agent profile not found")
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
repo store.Repository
|
||||
discovery *discovery.Registry
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, log *logger.Logger) *Controller {
|
||||
return &Controller{
|
||||
repo: repo,
|
||||
discovery: discoveryRegistry,
|
||||
logger: log.WithFields(zap.String("component", "agent-settings-controller")),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) {
|
||||
results, err := c.discovery.Detect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := make([]dto.AgentDiscoveryDTO, 0, len(results))
|
||||
for _, result := range results {
|
||||
payload = append(payload, dto.AgentDiscoveryDTO{
|
||||
Name: result.Name,
|
||||
SupportsMCP: result.SupportsMCP,
|
||||
MCPConfigPath: result.MCPConfigPath,
|
||||
InstallationPaths: result.InstallationPaths,
|
||||
Available: result.Available,
|
||||
MatchedPath: result.MatchedPath,
|
||||
})
|
||||
}
|
||||
return &dto.ListDiscoveryResponse{Agents: payload, Total: len(payload)}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, error) {
|
||||
agents, err := c.repo.ListAgents(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := make([]dto.AgentDTO, 0, len(agents))
|
||||
for _, agent := range agents {
|
||||
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload = append(payload, toAgentDTO(agent, profiles))
|
||||
}
|
||||
return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, error) {
|
||||
agent, err := c.repo.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := toAgentDTO(agent, profiles)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
type CreateAgentRequest struct {
|
||||
Name string
|
||||
WorkspaceID *string
|
||||
Profiles []CreateAgentProfileRequest
|
||||
}
|
||||
|
||||
type CreateAgentProfileRequest struct {
|
||||
Name string
|
||||
Model string
|
||||
AutoApprove bool
|
||||
DangerouslySkipPermissions bool
|
||||
Plan string
|
||||
}
|
||||
|
||||
func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) {
|
||||
if req.Name == "" {
|
||||
return nil, fmt.Errorf("name is required")
|
||||
}
|
||||
existing, err := c.repo.GetAgentByName(ctx, req.Name)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil && existing != nil {
|
||||
return nil, fmt.Errorf("agent already configured: %s", req.Name)
|
||||
}
|
||||
discoveryResults, err := c.discovery.Detect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var matched *discovery.Availability
|
||||
for _, result := range discoveryResults {
|
||||
if result.Name == req.Name {
|
||||
matched = &result
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
return nil, fmt.Errorf("unknown agent: %s", req.Name)
|
||||
}
|
||||
if !matched.Available {
|
||||
return nil, fmt.Errorf("agent not installed: %s", req.Name)
|
||||
}
|
||||
agent := &models.Agent{
|
||||
Name: matched.Name,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
SupportsMCP: matched.SupportsMCP,
|
||||
MCPConfigPath: matched.MCPConfigPath,
|
||||
}
|
||||
if err := c.repo.CreateAgent(ctx, agent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles := make([]*models.AgentProfile, 0, len(req.Profiles))
|
||||
for _, profileReq := range req.Profiles {
|
||||
profile := &models.AgentProfile{
|
||||
AgentID: agent.ID,
|
||||
Name: profileReq.Name,
|
||||
Model: profileReq.Model,
|
||||
AutoApprove: profileReq.AutoApprove,
|
||||
DangerouslySkipPermissions: profileReq.DangerouslySkipPermissions,
|
||||
Plan: profileReq.Plan,
|
||||
}
|
||||
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
result := toAgentDTO(agent, profiles)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
type UpdateAgentRequest struct {
|
||||
ID string
|
||||
WorkspaceID *string
|
||||
SupportsMCP *bool
|
||||
MCPConfigPath *string
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*dto.AgentDTO, error) {
|
||||
agent, err := c.repo.GetAgent(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
if req.WorkspaceID != nil {
|
||||
agent.WorkspaceID = req.WorkspaceID
|
||||
}
|
||||
if req.SupportsMCP != nil {
|
||||
agent.SupportsMCP = *req.SupportsMCP
|
||||
}
|
||||
if req.MCPConfigPath != nil {
|
||||
agent.MCPConfigPath = *req.MCPConfigPath
|
||||
}
|
||||
if err := c.repo.UpdateAgent(ctx, agent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := toAgentDTO(agent, profiles)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
|
||||
if err := c.repo.DeleteAgent(ctx, id); err != nil {
|
||||
if strings.Contains(err.Error(), "agent not found") {
|
||||
return ErrAgentNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CreateProfileRequest struct {
|
||||
AgentID string
|
||||
Name string
|
||||
Model string
|
||||
AutoApprove bool
|
||||
DangerouslySkipPermissions bool
|
||||
Plan string
|
||||
}
|
||||
|
||||
func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) {
|
||||
profile := &models.AgentProfile{
|
||||
AgentID: req.AgentID,
|
||||
Name: req.Name,
|
||||
Model: req.Model,
|
||||
AutoApprove: req.AutoApprove,
|
||||
DangerouslySkipPermissions: req.DangerouslySkipPermissions,
|
||||
Plan: req.Plan,
|
||||
}
|
||||
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := toProfileDTO(profile)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
ID string
|
||||
Name *string
|
||||
Model *string
|
||||
AutoApprove *bool
|
||||
DangerouslySkipPermissions *bool
|
||||
Plan *string
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) {
|
||||
profile, err := c.repo.GetAgentProfile(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, ErrAgentProfileNotFound
|
||||
}
|
||||
if req.Name != nil {
|
||||
profile.Name = *req.Name
|
||||
}
|
||||
if req.Model != nil {
|
||||
profile.Model = *req.Model
|
||||
}
|
||||
if req.AutoApprove != nil {
|
||||
profile.AutoApprove = *req.AutoApprove
|
||||
}
|
||||
if req.DangerouslySkipPermissions != nil {
|
||||
profile.DangerouslySkipPermissions = *req.DangerouslySkipPermissions
|
||||
}
|
||||
if req.Plan != nil {
|
||||
profile.Plan = *req.Plan
|
||||
}
|
||||
if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := toProfileDTO(profile)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Controller) DeleteProfile(ctx context.Context, id string) error {
|
||||
if err := c.repo.DeleteAgentProfile(ctx, id); err != nil {
|
||||
if strings.Contains(err.Error(), "agent profile not found") {
|
||||
return ErrAgentProfileNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO {
|
||||
profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
profileDTOs = append(profileDTOs, toProfileDTO(profile))
|
||||
}
|
||||
return dto.AgentDTO{
|
||||
ID: agent.ID,
|
||||
Name: agent.Name,
|
||||
WorkspaceID: agent.WorkspaceID,
|
||||
SupportsMCP: agent.SupportsMCP,
|
||||
MCPConfigPath: agent.MCPConfigPath,
|
||||
Profiles: profileDTOs,
|
||||
CreatedAt: agent.CreatedAt,
|
||||
UpdatedAt: agent.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO {
|
||||
return dto.AgentProfileDTO{
|
||||
ID: profile.ID,
|
||||
AgentID: profile.AgentID,
|
||||
Name: profile.Name,
|
||||
Model: profile.Model,
|
||||
AutoApprove: profile.AutoApprove,
|
||||
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
|
||||
Plan: profile.Plan,
|
||||
CreatedAt: profile.CreatedAt,
|
||||
UpdatedAt: profile.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type AgentProfileDTO struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
AutoApprove bool `json:"auto_approve"`
|
||||
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
|
||||
Plan string `json:"plan"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgentDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||
SupportsMCP bool `json:"supports_mcp"`
|
||||
MCPConfigPath string `json:"mcp_config_path,omitempty"`
|
||||
Profiles []AgentProfileDTO `json:"profiles"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListAgentsResponse struct {
|
||||
Agents []AgentDTO `json:"agents"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type AgentDiscoveryDTO struct {
|
||||
Name string `json:"name"`
|
||||
SupportsMCP bool `json:"supports_mcp"`
|
||||
MCPConfigPath string `json:"mcp_config_path,omitempty"`
|
||||
InstallationPaths []string `json:"installation_paths,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
MatchedPath string `json:"matched_path,omitempty"`
|
||||
}
|
||||
|
||||
type ListDiscoveryResponse struct {
|
||||
Agents []AgentDiscoveryDTO `json:"agents"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/settings/controller"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
controller *controller.Controller
|
||||
hub Broadcaster
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
type Broadcaster interface {
|
||||
Broadcast(msg *ws.Message)
|
||||
}
|
||||
|
||||
func NewHandlers(ctrl *controller.Controller, hub Broadcaster, log *logger.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
controller: ctrl,
|
||||
hub: hub,
|
||||
logger: log.WithFields(zap.String("component", "agent-settings-handlers")),
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, hub Broadcaster, log *logger.Logger) {
|
||||
handlers := NewHandlers(ctrl, hub, log)
|
||||
handlers.registerHTTP(router)
|
||||
}
|
||||
|
||||
func (h *Handlers) registerHTTP(router *gin.Engine) {
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/agents/discovery", h.httpDiscoverAgents)
|
||||
api.GET("/agents", h.httpListAgents)
|
||||
api.POST("/agents", h.httpCreateAgent)
|
||||
api.GET("/agents/:id", h.httpGetAgent)
|
||||
api.PATCH("/agents/:id", h.httpUpdateAgent)
|
||||
api.DELETE("/agents/:id", h.httpDeleteAgent)
|
||||
api.POST("/agents/:id/profiles", h.httpCreateProfile)
|
||||
api.PATCH("/agent-profiles/:id", h.httpUpdateProfile)
|
||||
api.DELETE("/agent-profiles/:id", h.httpDeleteProfile)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpDiscoverAgents(c *gin.Context) {
|
||||
resp, err := h.controller.ListDiscovery(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("failed to discover agents", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to discover agents"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpListAgents(c *gin.Context) {
|
||||
resp, err := h.controller.ListAgents(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list agents", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type createAgentRequest struct {
|
||||
Name string `json:"name"`
|
||||
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||
Profiles []createAgentProfileRequest `json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
type createAgentProfileRequest struct {
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
AutoApprove bool `json:"auto_approve"`
|
||||
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpCreateAgent(c *gin.Context) {
|
||||
var body createAgentRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
profiles := make([]controller.CreateAgentProfileRequest, 0, len(body.Profiles))
|
||||
for _, profile := range body.Profiles {
|
||||
profiles = append(profiles, controller.CreateAgentProfileRequest{
|
||||
Name: profile.Name,
|
||||
Model: profile.Model,
|
||||
AutoApprove: profile.AutoApprove,
|
||||
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
|
||||
Plan: profile.Plan,
|
||||
})
|
||||
}
|
||||
resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{
|
||||
Name: body.Name,
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
Profiles: profiles,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create agent", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpGetAgent(c *gin.Context) {
|
||||
resp, err := h.controller.GetAgent(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if err == controller.ErrAgentNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to get agent", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get agent"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type updateAgentRequest struct {
|
||||
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||
SupportsMCP *bool `json:"supports_mcp,omitempty"`
|
||||
MCPConfigPath *string `json:"mcp_config_path,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpUpdateAgent(c *gin.Context) {
|
||||
var body updateAgentRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.UpdateAgent(c.Request.Context(), controller.UpdateAgentRequest{
|
||||
ID: c.Param("id"),
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
SupportsMCP: body.SupportsMCP,
|
||||
MCPConfigPath: body.MCPConfigPath,
|
||||
})
|
||||
if err != nil {
|
||||
if err == controller.ErrAgentNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to update agent", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update agent"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpDeleteAgent(c *gin.Context) {
|
||||
if err := h.controller.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
|
||||
if err == controller.ErrAgentNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to delete agent", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete agent"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
type createProfileRequest struct {
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
AutoApprove bool `json:"auto_approve"`
|
||||
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpCreateProfile(c *gin.Context) {
|
||||
var body createProfileRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.CreateProfile(c.Request.Context(), controller.CreateProfileRequest{
|
||||
AgentID: c.Param("id"),
|
||||
Name: body.Name,
|
||||
Model: body.Model,
|
||||
AutoApprove: body.AutoApprove,
|
||||
DangerouslySkipPermissions: body.DangerouslySkipPermissions,
|
||||
Plan: body.Plan,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create profile", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create profile"})
|
||||
return
|
||||
}
|
||||
if h.hub != nil {
|
||||
notification, _ := ws.NewNotification(ws.ActionAgentProfileCreated, gin.H{
|
||||
"profile_id": resp.ID,
|
||||
"agent_id": resp.AgentID,
|
||||
})
|
||||
h.hub.Broadcast(notification)
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
AutoApprove *bool `json:"auto_approve,omitempty"`
|
||||
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
|
||||
Plan *string `json:"plan,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpUpdateProfile(c *gin.Context) {
|
||||
var body updateProfileRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.UpdateProfile(c.Request.Context(), controller.UpdateProfileRequest{
|
||||
ID: c.Param("id"),
|
||||
Name: body.Name,
|
||||
Model: body.Model,
|
||||
AutoApprove: body.AutoApprove,
|
||||
DangerouslySkipPermissions: body.DangerouslySkipPermissions,
|
||||
Plan: body.Plan,
|
||||
})
|
||||
if err != nil {
|
||||
if err == controller.ErrAgentProfileNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to update profile", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"})
|
||||
return
|
||||
}
|
||||
if h.hub != nil {
|
||||
notification, _ := ws.NewNotification(ws.ActionAgentProfileUpdated, gin.H{
|
||||
"profile_id": resp.ID,
|
||||
"agent_id": resp.AgentID,
|
||||
})
|
||||
h.hub.Broadcast(notification)
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpDeleteProfile(c *gin.Context) {
|
||||
if err := h.controller.DeleteProfile(c.Request.Context(), c.Param("id")); err != nil {
|
||||
if err == controller.ErrAgentProfileNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to delete profile", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete profile"})
|
||||
return
|
||||
}
|
||||
if h.hub != nil {
|
||||
notification, _ := ws.NewNotification(ws.ActionAgentProfileDeleted, gin.H{
|
||||
"profile_id": c.Param("id"),
|
||||
})
|
||||
h.hub.Broadcast(notification)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||
SupportsMCP bool `json:"supports_mcp"`
|
||||
MCPConfigPath string `json:"mcp_config_path,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgentProfile struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
AutoApprove bool `json:"auto_approve"`
|
||||
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
|
||||
Plan string `json:"plan"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/settings/models"
|
||||
)
|
||||
|
||||
type SQLiteRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
var _ Repository = (*SQLiteRepository)(nil)
|
||||
|
||||
func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) {
|
||||
normalizedPath := normalizeSQLitePath(dbPath)
|
||||
if err := ensureSQLiteDir(normalizedPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare database path: %w", err)
|
||||
}
|
||||
if err := ensureSQLiteFile(normalizedPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database file: %w", err)
|
||||
}
|
||||
dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_mode=rwc", normalizedPath)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
repo := &SQLiteRepository{db: db}
|
||||
if err := repo.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func ensureSQLiteDir(dbPath string) error {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
func ensureSQLiteFile(dbPath string) error {
|
||||
file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func normalizeSQLitePath(dbPath string) string {
|
||||
if dbPath == "" {
|
||||
return dbPath
|
||||
}
|
||||
abs, err := filepath.Abs(dbPath)
|
||||
if err != nil {
|
||||
return dbPath
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) initSchema() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
workspace_id TEXT DEFAULT NULL,
|
||||
supports_mcp INTEGER NOT NULL DEFAULT 0,
|
||||
mcp_config_path TEXT DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
auto_approve INTEGER NOT NULL DEFAULT 0,
|
||||
dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0,
|
||||
plan TEXT DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS idx_agents_name;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_name ON agents(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id);
|
||||
`
|
||||
_, err := r.db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) Close() error {
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) CreateAgent(ctx context.Context, agent *models.Agent) error {
|
||||
if agent.ID == "" {
|
||||
agent.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
agent.CreatedAt = now
|
||||
agent.UpdatedAt = now
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, agent.ID, agent.Name, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) GetAgent(ctx context.Context, id string) (*models.Agent, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at
|
||||
FROM agents WHERE id = ?
|
||||
`, id)
|
||||
return scanAgent(row)
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) GetAgentByName(ctx context.Context, name string) (*models.Agent, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at
|
||||
FROM agents WHERE name = ?
|
||||
`, name)
|
||||
return scanAgent(row)
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error {
|
||||
agent.UpdatedAt = time.Now().UTC()
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent not found: %s", agent.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) DeleteAgent(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM agents WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) ListAgents(ctx context.Context) ([]*models.Agent, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at
|
||||
FROM agents ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*models.Agent
|
||||
for rows.Next() {
|
||||
agent, err := scanAgent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, agent)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
|
||||
if profile.ID == "" {
|
||||
profile.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
profile.CreatedAt = now
|
||||
profile.UpdatedAt = now
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO agent_profiles (id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, profile.ID, profile.AgentID, profile.Name, profile.Model, boolToInt(profile.AutoApprove),
|
||||
boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.CreatedAt, profile.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
|
||||
profile.UpdatedAt = time.Now().UTC()
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE agent_profiles
|
||||
SET name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, plan = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, profile.Name, profile.Model, boolToInt(profile.AutoApprove),
|
||||
boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.UpdatedAt, profile.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent profile not found: %s", profile.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) DeleteAgentProfile(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM agent_profiles WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("agent profile not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at
|
||||
FROM agent_profiles WHERE id = ?
|
||||
`, id)
|
||||
return scanAgentProfile(row)
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at
|
||||
FROM agent_profiles WHERE agent_id = ? ORDER BY created_at DESC
|
||||
`, agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*models.AgentProfile
|
||||
for rows.Next() {
|
||||
profile, err := scanAgentProfile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, profile)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func scanAgent(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*models.Agent, error) {
|
||||
agent := &models.Agent{}
|
||||
var supportsMCP int
|
||||
var workspaceID sql.NullString
|
||||
if err := scanner.Scan(
|
||||
&agent.ID,
|
||||
&agent.Name,
|
||||
&workspaceID,
|
||||
&supportsMCP,
|
||||
&agent.MCPConfigPath,
|
||||
&agent.CreatedAt,
|
||||
&agent.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if workspaceID.Valid {
|
||||
agent.WorkspaceID = &workspaceID.String
|
||||
}
|
||||
agent.SupportsMCP = supportsMCP == 1
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func scanAgentProfile(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*models.AgentProfile, error) {
|
||||
profile := &models.AgentProfile{}
|
||||
var autoApprove int
|
||||
var skipPermissions int
|
||||
if err := scanner.Scan(
|
||||
&profile.ID,
|
||||
&profile.AgentID,
|
||||
&profile.Name,
|
||||
&profile.Model,
|
||||
&autoApprove,
|
||||
&skipPermissions,
|
||||
&profile.Plan,
|
||||
&profile.CreatedAt,
|
||||
&profile.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile.AutoApprove = autoApprove == 1
|
||||
profile.DangerouslySkipPermissions = skipPermissions == 1
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/settings/models"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CreateAgent(ctx context.Context, agent *models.Agent) error
|
||||
GetAgent(ctx context.Context, id string) (*models.Agent, error)
|
||||
GetAgentByName(ctx context.Context, name string) (*models.Agent, error)
|
||||
UpdateAgent(ctx context.Context, agent *models.Agent) error
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
ListAgents(ctx context.Context) ([]*models.Agent, error)
|
||||
|
||||
CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error
|
||||
UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error
|
||||
DeleteAgentProfile(ctx context.Context, id string) error
|
||||
GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error)
|
||||
ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
@@ -131,6 +131,10 @@ const (
|
||||
ActionEnvironmentUpdated = "environment.updated"
|
||||
ActionEnvironmentDeleted = "environment.deleted"
|
||||
|
||||
ActionAgentProfileDeleted = "agent.profile.deleted"
|
||||
ActionAgentProfileCreated = "agent.profile.created"
|
||||
ActionAgentProfileUpdated = "agent.profile.updated"
|
||||
|
||||
// Permission request actions (agent -> user -> agent)
|
||||
ActionPermissionRequested = "permission.requested" // Agent requesting permission
|
||||
ActionPermissionRespond = "permission.respond" // User responding to permission request
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
'use server';
|
||||
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
ListAgentsResponse,
|
||||
ListAgentDiscoveryResponse,
|
||||
} from '@/lib/types/http';
|
||||
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export async function listAgentDiscoveryAction(): Promise<ListAgentDiscoveryResponse> {
|
||||
return fetchJson<ListAgentDiscoveryResponse>(`${apiBaseUrl}/api/v1/agents/discovery`);
|
||||
}
|
||||
|
||||
export async function listAgentsAction(): Promise<ListAgentsResponse> {
|
||||
return fetchJson<ListAgentsResponse>(`${apiBaseUrl}/api/v1/agents`);
|
||||
}
|
||||
|
||||
export async function createAgentAction(payload: {
|
||||
name: string;
|
||||
workspace_id?: string | null;
|
||||
profiles?: Array<{
|
||||
name: string;
|
||||
model: string;
|
||||
auto_approve: boolean;
|
||||
dangerously_skip_permissions: boolean;
|
||||
plan: string;
|
||||
}>;
|
||||
}): Promise<Agent> {
|
||||
return fetchJson<Agent>(`${apiBaseUrl}/api/v1/agents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAgentAction(
|
||||
id: string,
|
||||
payload: { workspace_id?: string | null; supports_mcp?: boolean; mcp_config_path?: string | null }
|
||||
): Promise<Agent> {
|
||||
return fetchJson<Agent>(`${apiBaseUrl}/api/v1/agents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAgentAction(id: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/agents/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function createAgentProfileAction(
|
||||
agentId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
model: string;
|
||||
auto_approve: boolean;
|
||||
dangerously_skip_permissions: boolean;
|
||||
plan: string;
|
||||
}
|
||||
): Promise<AgentProfile> {
|
||||
return fetchJson<AgentProfile>(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAgentProfileAction(
|
||||
id: string,
|
||||
payload: Partial<Pick<AgentProfile, 'name' | 'model' | 'auto_approve' | 'dangerously_skip_permissions' | 'plan'>>
|
||||
): Promise<AgentProfile> {
|
||||
return fetchJson<AgentProfile>(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAgentProfileAction(id: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -1,251 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kandev/ui/card';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { Switch } from '@kandev/ui/switch';
|
||||
import { Slider } from '@kandev/ui/slider';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kandev/ui/dialog';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
|
||||
import type { AgentProfile, AgentType } from '@/lib/settings/types';
|
||||
|
||||
const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
|
||||
'claude-code': [
|
||||
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
|
||||
{ value: 'claude-opus-4', label: 'Claude Opus 4' },
|
||||
{ value: 'claude-haiku-4', label: 'Claude Haiku 4' },
|
||||
],
|
||||
'codex': [
|
||||
{ value: 'codex-v2', label: 'Codex v2' },
|
||||
{ value: 'codex-v1', label: 'Codex v1' },
|
||||
],
|
||||
'auggie': [
|
||||
{ value: 'auggie-v1', label: 'Auggie v1' },
|
||||
{ value: 'auggie-lite', label: 'Auggie Lite' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function AgentEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
export default function AgentEditPage() {
|
||||
const router = useRouter();
|
||||
const [agent, setAgent] = useState<AgentProfile | undefined>(
|
||||
SETTINGS_DATA.agents.find((a) => a.id === id)
|
||||
);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Agent not found</p>
|
||||
<Button className="mt-4" onClick={() => router.push('/settings/agents')}>
|
||||
Go to Agents
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteAgent = () => {
|
||||
if (deleteConfirmText === 'delete') {
|
||||
router.push('/settings/agents');
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
router.replace('/settings/agents');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{agent.name}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure agent behavior and capabilities
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Name</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Input
|
||||
value={agent.name}
|
||||
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
|
||||
placeholder="Code Assistant"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Agent Type</Label>
|
||||
<Select
|
||||
value={agent.agent}
|
||||
onValueChange={(value) => {
|
||||
const agentType = value as AgentType;
|
||||
setAgent({
|
||||
...agent,
|
||||
agent: agentType,
|
||||
model: MODEL_OPTIONS[agentType][0].value
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude-code">Claude Code</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="auggie">Auggie</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Select
|
||||
value={agent.model}
|
||||
onValueChange={(value) => setAgent({ ...agent, model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODEL_OPTIONS[agent.agent].map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Advanced Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Temperature</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Controls randomness in responses (0 = deterministic, 1 = creative)
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{agent.temperature.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[agent.temperature]}
|
||||
onValueChange={([value]) => setAgent({ ...agent, temperature: value })}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Auto-approve Actions</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically execute tool calls without confirmation
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={agent.autoApprove}
|
||||
onCheckedChange={(checked) => setAgent({ ...agent, autoApprove: checked })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that will permanently delete this agent
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Delete this agent</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once deleted, all configuration will be permanently removed
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Agent
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the agent "
|
||||
{agent.name}" and all its data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm">
|
||||
Please type <span className="font-mono font-bold">delete</span> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="delete"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAgent}
|
||||
disabled={deleteConfirmText !== 'delete'}
|
||||
>
|
||||
Delete Agent
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">Manage agents from the main Agents page.</p>
|
||||
<Button className="mt-4" onClick={() => router.push('/settings/agents')}>
|
||||
Go to Agents
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { Switch } from '@kandev/ui/switch';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
|
||||
import {
|
||||
createAgentAction,
|
||||
createAgentProfileAction,
|
||||
deleteAgentProfileAction,
|
||||
listAgentDiscoveryAction,
|
||||
listAgentsAction,
|
||||
updateAgentAction,
|
||||
updateAgentProfileAction,
|
||||
} from '@/app/actions/agents';
|
||||
import type { Agent, AgentDiscovery, AgentProfile } from '@/lib/types/http';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
|
||||
type DraftAgent = Agent & { isNew?: boolean };
|
||||
type DraftProfile = AgentProfile & { isNew?: boolean };
|
||||
|
||||
type AgentSetupFormProps = {
|
||||
initialAgent: DraftAgent;
|
||||
savedAgent: Agent | null;
|
||||
discoveryAgent: AgentDiscovery | undefined;
|
||||
refreshAgents: () => Promise<unknown>;
|
||||
onToastError: (error: unknown) => void;
|
||||
};
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
copilot: 'Copilot',
|
||||
};
|
||||
|
||||
const createDraftProfile = (agentId: string): DraftProfile => ({
|
||||
id: `draft-${generateUUID()}`,
|
||||
agent_id: agentId,
|
||||
name: '',
|
||||
model: '',
|
||||
auto_approve: false,
|
||||
dangerously_skip_permissions: false,
|
||||
plan: '',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
isNew: true,
|
||||
});
|
||||
|
||||
const cloneAgent = (agent: Agent): DraftAgent => ({
|
||||
...agent,
|
||||
workspace_id: agent.workspace_id ?? null,
|
||||
mcp_config_path: agent.mcp_config_path ?? '',
|
||||
profiles: agent.profiles.map((profile) => ({ ...profile })),
|
||||
});
|
||||
|
||||
const ensureProfiles = (agent: DraftAgent): DraftAgent => {
|
||||
if (agent.profiles.length > 0) {
|
||||
return agent;
|
||||
}
|
||||
return {
|
||||
...agent,
|
||||
profiles: [createDraftProfile(agent.id)],
|
||||
};
|
||||
};
|
||||
|
||||
function AgentSetupForm({
|
||||
initialAgent,
|
||||
savedAgent,
|
||||
discoveryAgent,
|
||||
refreshAgents,
|
||||
onToastError,
|
||||
}: AgentSetupFormProps) {
|
||||
const router = useRouter();
|
||||
const [draftAgent, setDraftAgent] = useState<DraftAgent>(initialAgent);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const isProfileDirty = (draft: DraftProfile, saved?: AgentProfile) => {
|
||||
if (!saved) return true;
|
||||
return (
|
||||
draft.name !== saved.name ||
|
||||
draft.model !== saved.model ||
|
||||
draft.auto_approve !== saved.auto_approve ||
|
||||
draft.dangerously_skip_permissions !== saved.dangerously_skip_permissions ||
|
||||
draft.plan !== saved.plan
|
||||
);
|
||||
};
|
||||
|
||||
const isAgentDirty = useMemo(() => {
|
||||
if (!draftAgent) return false;
|
||||
if (!savedAgent) return true;
|
||||
if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) return true;
|
||||
if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) return true;
|
||||
if (draftAgent.profiles.length !== savedAgent.profiles.length) return true;
|
||||
const savedProfiles = new Map(savedAgent.profiles.map((profile) => [profile.id, profile]));
|
||||
for (const profile of draftAgent.profiles) {
|
||||
const savedProfile = savedProfiles.get(profile.id);
|
||||
if (!savedProfile || isProfileDirty(profile, savedProfile)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [draftAgent, savedAgent]);
|
||||
|
||||
const handleAddProfile = () => {
|
||||
setDraftAgent((current) => ({
|
||||
...current,
|
||||
profiles: [...current.profiles, createDraftProfile(current.id)],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveProfile = (profileId: string) => {
|
||||
setDraftAgent((current) => {
|
||||
const remaining = current.profiles.filter((profile) => profile.id !== profileId);
|
||||
return {
|
||||
...current,
|
||||
profiles: remaining.length > 0 ? remaining : [createDraftProfile(current.id)],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileChange = (profileId: string, patch: Partial<DraftProfile>) => {
|
||||
setDraftAgent((current) => ({
|
||||
...current,
|
||||
profiles: current.profiles.map((profile) =>
|
||||
profile.id === profileId ? { ...profile, ...patch } : profile
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAgentFieldChange = (patch: Partial<DraftAgent>) => {
|
||||
setDraftAgent((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveStatus('loading');
|
||||
try {
|
||||
if (!savedAgent) {
|
||||
const created = await createAgentAction({
|
||||
name: draftAgent.name,
|
||||
workspace_id: draftAgent.workspace_id,
|
||||
profiles: draftAgent.profiles.map((profile) => ({
|
||||
name: profile.name,
|
||||
model: profile.model,
|
||||
auto_approve: profile.auto_approve,
|
||||
dangerously_skip_permissions: profile.dangerously_skip_permissions,
|
||||
plan: profile.plan,
|
||||
})),
|
||||
});
|
||||
if ((draftAgent.mcp_config_path ?? '') !== (created.mcp_config_path ?? '')) {
|
||||
await updateAgentAction(created.id, { mcp_config_path: draftAgent.mcp_config_path ?? '' });
|
||||
}
|
||||
await refreshAgents();
|
||||
router.replace(`/settings/agents/${encodeURIComponent(created.name)}`);
|
||||
} else {
|
||||
const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {};
|
||||
if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) {
|
||||
agentPatch.workspace_id = draftAgent.workspace_id ?? null;
|
||||
}
|
||||
if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) {
|
||||
agentPatch.mcp_config_path = draftAgent.mcp_config_path ?? '';
|
||||
}
|
||||
if (Object.keys(agentPatch).length > 0) {
|
||||
await updateAgentAction(savedAgent.id, agentPatch);
|
||||
}
|
||||
|
||||
const savedProfilesById = new Map(savedAgent.profiles.map((profile) => [profile.id, profile]));
|
||||
for (const profile of draftAgent.profiles) {
|
||||
const savedProfile = savedProfilesById.get(profile.id);
|
||||
if (!savedProfile) {
|
||||
await createAgentProfileAction(savedAgent.id, {
|
||||
name: profile.name,
|
||||
model: profile.model,
|
||||
auto_approve: profile.auto_approve,
|
||||
dangerously_skip_permissions: profile.dangerously_skip_permissions,
|
||||
plan: profile.plan,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isProfileDirty(profile, savedProfile)) {
|
||||
await updateAgentProfileAction(profile.id, {
|
||||
name: profile.name,
|
||||
model: profile.model,
|
||||
auto_approve: profile.auto_approve,
|
||||
dangerously_skip_permissions: profile.dangerously_skip_permissions,
|
||||
plan: profile.plan,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const savedProfile of savedAgent.profiles) {
|
||||
const stillExists = draftAgent.profiles.some((profile) => profile.id === savedProfile.id);
|
||||
if (!stillExists) {
|
||||
await deleteAgentProfileAction(savedProfile.id);
|
||||
}
|
||||
}
|
||||
|
||||
await refreshAgents();
|
||||
}
|
||||
setSaveStatus('success');
|
||||
} catch (error) {
|
||||
setSaveStatus('error');
|
||||
onToastError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{AGENT_LABELS[draftAgent.name] ?? draftAgent.name}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground border border-muted-foreground/30 rounded-full px-2 py-1">
|
||||
{discoveryAgent?.matched_path ?? 'Installation not detected'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure profiles and defaults for this agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>{AGENT_LABELS[draftAgent.name] ?? draftAgent.name} Profiles</CardTitle>
|
||||
{isAgentDirty && <UnsavedChangesBadge />}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleAddProfile}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add profile
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{draftAgent.supports_mcp && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mcp-config-path">MCP Config Path</Label>
|
||||
<Input
|
||||
id="mcp-config-path"
|
||||
value={draftAgent.mcp_config_path ?? ''}
|
||||
onChange={(event) => handleAgentFieldChange({ mcp_config_path: event.target.value })}
|
||||
placeholder="~/.copilot/mcp-config.json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{draftAgent.profiles.map((profile) => (
|
||||
<Card key={profile.id} className="border-muted">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>Profile name</Label>
|
||||
<Input
|
||||
value={profile.name}
|
||||
onChange={(event) =>
|
||||
handleProfileChange(profile.id, { name: event.target.value })
|
||||
}
|
||||
placeholder="Default profile"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleRemoveProfile(profile.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Input
|
||||
value={profile.model}
|
||||
onChange={(event) =>
|
||||
handleProfileChange(profile.id, { model: event.target.value })
|
||||
}
|
||||
placeholder="model-id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Append Prompt</Label>
|
||||
<Textarea
|
||||
value={profile.plan}
|
||||
onChange={(event) =>
|
||||
handleProfileChange(profile.id, { plan: event.target.value })
|
||||
}
|
||||
placeholder="Extra text appended to the agent prompt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Auto-approve</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically approve tool calls.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={profile.auto_approve}
|
||||
onCheckedChange={(checked) =>
|
||||
handleProfileChange(profile.id, { auto_approve: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Skip permissions</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip permission checks when running tools.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={profile.dangerously_skip_permissions}
|
||||
onCheckedChange={(checked) =>
|
||||
handleProfileChange(profile.id, { dangerously_skip_permissions: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
<div className="flex justify-end px-6 pb-6">
|
||||
<UnsavedSaveButton
|
||||
isDirty={isAgentDirty}
|
||||
isLoading={saveStatus === 'loading'}
|
||||
status={saveStatus}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentSetupPage() {
|
||||
const { toast } = useToast();
|
||||
const params = useParams();
|
||||
const agentKey = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
|
||||
const decodedKey = decodeURIComponent(agentKey ?? '');
|
||||
const { run: runListDiscovery, data: discoveryData } = useRequest(listAgentDiscoveryAction);
|
||||
const { run: runListAgents, data: agentsData } = useRequest(listAgentsAction);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([runListDiscovery(), runListAgents()]).catch((error) => {
|
||||
toast({
|
||||
title: 'Failed to load agent settings',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
}, [runListAgents, runListDiscovery, toast]);
|
||||
|
||||
const discoveryAgent = useMemo(() => {
|
||||
const agents = discoveryData?.agents ?? [];
|
||||
return agents.find((agent) => agent.name === decodedKey);
|
||||
}, [decodedKey, discoveryData?.agents]);
|
||||
|
||||
const savedAgent = useMemo(() => {
|
||||
const agents = agentsData?.agents ?? [];
|
||||
return agents.find((agent) => agent.id === decodedKey || agent.name === decodedKey) ?? null;
|
||||
}, [decodedKey, agentsData?.agents]);
|
||||
|
||||
const initialAgent = useMemo(() => {
|
||||
if (!decodedKey) return null;
|
||||
if (savedAgent) {
|
||||
return ensureProfiles(cloneAgent(savedAgent));
|
||||
}
|
||||
if (discoveryAgent) {
|
||||
const draft: DraftAgent = {
|
||||
id: `draft-${generateUUID()}`,
|
||||
name: discoveryAgent.name,
|
||||
workspace_id: null,
|
||||
supports_mcp: discoveryAgent.supports_mcp,
|
||||
mcp_config_path: discoveryAgent.mcp_config_path ?? '',
|
||||
profiles: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
isNew: true,
|
||||
};
|
||||
return ensureProfiles(draft);
|
||||
}
|
||||
return null;
|
||||
}, [decodedKey, discoveryAgent, savedAgent]);
|
||||
|
||||
if (!initialAgent && (discoveryData?.agents?.length ?? 0) > 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">Agent not found.</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/settings/agents">Back to Agents</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!initialAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleToastError = (error: unknown) => {
|
||||
toast({
|
||||
title: 'Failed to save agent',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AgentSetupForm
|
||||
key={initialAgent.id}
|
||||
initialAgent={initialAgent}
|
||||
savedAgent={savedAgent}
|
||||
discoveryAgent={discoveryAgent}
|
||||
refreshAgents={runListAgents}
|
||||
onToastError={handleToastError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { Switch } from '@kandev/ui/switch';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
|
||||
import {
|
||||
deleteAgentProfileAction,
|
||||
listAgentsAction,
|
||||
updateAgentProfileAction,
|
||||
} from '@/app/actions/agents';
|
||||
import type { Agent, AgentProfile } from '@/lib/types/http';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
copilot: 'Copilot',
|
||||
};
|
||||
|
||||
type ProfileEditorProps = {
|
||||
agent: Agent;
|
||||
profile: AgentProfile;
|
||||
onRefresh: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function ProfileEditor({ agent, profile, onRefresh }: ProfileEditorProps) {
|
||||
const { toast } = useToast();
|
||||
const [draft, setDraft] = useState<AgentProfile>({ ...profile });
|
||||
const [savedProfile, setSavedProfile] = useState<AgentProfile>(profile);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
return (
|
||||
draft.name !== savedProfile.name ||
|
||||
draft.model !== savedProfile.model ||
|
||||
draft.auto_approve !== savedProfile.auto_approve ||
|
||||
draft.dangerously_skip_permissions !== savedProfile.dangerously_skip_permissions ||
|
||||
draft.plan !== savedProfile.plan
|
||||
);
|
||||
}, [draft, savedProfile]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveStatus('loading');
|
||||
try {
|
||||
const updated = await updateAgentProfileAction(draft.id, {
|
||||
name: draft.name,
|
||||
model: draft.model,
|
||||
auto_approve: draft.auto_approve,
|
||||
dangerously_skip_permissions: draft.dangerously_skip_permissions,
|
||||
plan: draft.plan,
|
||||
});
|
||||
setSavedProfile(updated);
|
||||
setDraft(updated);
|
||||
await onRefresh();
|
||||
setSaveStatus('success');
|
||||
} catch (error) {
|
||||
setSaveStatus('error');
|
||||
toast({
|
||||
title: 'Failed to save profile',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProfile = async () => {
|
||||
try {
|
||||
await deleteAgentProfileAction(draft.id);
|
||||
window.location.assign('/settings/agents');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete profile',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{savedProfile.name}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{AGENT_LABELS[agent.name] ?? agent.name} profile settings
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isDirty && <UnsavedChangesBadge />}
|
||||
<UnsavedSaveButton
|
||||
isDirty={isDirty}
|
||||
isLoading={saveStatus === 'loading'}
|
||||
status={saveStatus}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>Profile settings</span>
|
||||
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Profile name</Label>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
|
||||
placeholder="Default profile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Input
|
||||
value={draft.model}
|
||||
onChange={(event) => setDraft({ ...draft, model: event.target.value })}
|
||||
placeholder="model-id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Append Prompt</Label>
|
||||
<Textarea
|
||||
value={draft.plan}
|
||||
onChange={(event) => setDraft({ ...draft, plan: event.target.value })}
|
||||
placeholder="Extra text appended to the agent prompt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Auto-approve</Label>
|
||||
<p className="text-xs text-muted-foreground">Automatically approve tool calls.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.auto_approve}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft({ ...draft, auto_approve: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Skip permissions</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip permission checks when running tools.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.dangerously_skip_permissions}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft({ ...draft, dangerously_skip_permissions: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Delete profile</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Remove this profile</p>
|
||||
<p className="text-xs text-muted-foreground">This action cannot be undone.</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleDeleteProfile}>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentProfilePage() {
|
||||
const { toast } = useToast();
|
||||
const params = useParams();
|
||||
const agentParam = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
|
||||
const profileParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId;
|
||||
const agentKey = decodeURIComponent(agentParam ?? '');
|
||||
const profileId = profileParam ?? '';
|
||||
const { run: runListAgents, data, isLoading } = useRequest(listAgentsAction);
|
||||
|
||||
useEffect(() => {
|
||||
runListAgents().catch((error) => {
|
||||
toast({
|
||||
title: 'Failed to load profile',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
}, [runListAgents, toast]);
|
||||
|
||||
const agent = useMemo(() => {
|
||||
const agents = data?.agents ?? [];
|
||||
return agents.find((item) => item.name === agentKey) ?? null;
|
||||
}, [agentKey, data?.agents]);
|
||||
|
||||
const profile = useMemo(() => {
|
||||
return agent?.profiles.find((item) => item.id === profileId) ?? null;
|
||||
}, [agent?.profiles, profileId]);
|
||||
|
||||
if (!agent || !profile) {
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">Profile not found.</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/settings/agents">Back to Agents</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileEditor
|
||||
key={profile.id}
|
||||
agent={agent}
|
||||
profile={profile}
|
||||
onRefresh={runListAgents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { IconAlertTriangle, IconSettings } from '@tabler/icons-react';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { AgentCard } from '@/components/settings/agent-card';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { AgentProfile, AgentType } from '@/lib/settings/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { listAgentDiscoveryAction, listAgentsAction } from '@/app/actions/agents';
|
||||
import type { Agent, AgentDiscovery } from '@/lib/types/http';
|
||||
|
||||
const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
|
||||
{ value: 'claude-code', label: 'Claude Code' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'auggie', label: 'Auggie' },
|
||||
];
|
||||
|
||||
const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
|
||||
'claude-code': [
|
||||
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
|
||||
{ value: 'claude-opus-4', label: 'Claude Opus 4' },
|
||||
{ value: 'claude-haiku-4', label: 'Claude Haiku 4' },
|
||||
],
|
||||
'codex': [
|
||||
{ value: 'codex-v2', label: 'Codex v2' },
|
||||
{ value: 'codex-v1', label: 'Codex v1' },
|
||||
],
|
||||
'auggie': [
|
||||
{ value: 'auggie-v1', label: 'Auggie v1' },
|
||||
{ value: 'auggie-lite', label: 'Auggie Lite' },
|
||||
],
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
copilot: 'Copilot',
|
||||
};
|
||||
|
||||
export default function AgentsSettingsPage() {
|
||||
const [agents, setAgents] = useState<AgentProfile[]>(SETTINGS_DATA.agents);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newAgent, setNewAgent] = useState<{
|
||||
agent: AgentType;
|
||||
name: string;
|
||||
model: string;
|
||||
autoApprove: boolean;
|
||||
temperature: number;
|
||||
}>({
|
||||
agent: 'claude-code',
|
||||
name: '',
|
||||
model: 'claude-sonnet-4.5',
|
||||
autoApprove: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const [discoveryAgents, setDiscoveryAgents] = useState<AgentDiscovery[]>([]);
|
||||
const [savedAgents, setSavedAgents] = useState<Agent[]>([]);
|
||||
const { run: runListDiscovery } = useRequest(listAgentDiscoveryAction);
|
||||
const { run: runListAgents } = useRequest(listAgentsAction);
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const profile: AgentProfile = {
|
||||
id: generateUUID(),
|
||||
...newAgent,
|
||||
};
|
||||
setAgents([...agents, profile]);
|
||||
setIsAdding(false);
|
||||
setNewAgent({
|
||||
agent: 'claude-code',
|
||||
name: '',
|
||||
model: 'claude-sonnet-4.5',
|
||||
autoApprove: false,
|
||||
temperature: 0.7,
|
||||
useEffect(() => {
|
||||
Promise.all([runListDiscovery(), runListAgents()])
|
||||
.then(([discovery, agents]) => {
|
||||
setDiscoveryAgents(discovery.agents);
|
||||
setSavedAgents(agents.agents);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'Failed to load agent settings',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
};
|
||||
}, [runListAgents, runListDiscovery, toast]);
|
||||
|
||||
const handleAgentTypeChange = (type: AgentType) => {
|
||||
const defaultModel = MODEL_OPTIONS[type][0].value;
|
||||
setNewAgent({ ...newAgent, agent: type, model: defaultModel });
|
||||
};
|
||||
const installedAgents = useMemo(
|
||||
() => discoveryAgents.filter((agent) => agent.available),
|
||||
[discoveryAgents]
|
||||
);
|
||||
|
||||
const savedAgentsByName = useMemo(
|
||||
() => new Map(savedAgents.map((agent) => [agent.name, agent])),
|
||||
[savedAgents]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Agents</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure agent profiles and their behavior
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setIsAdding(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Agent Profile
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Agents</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Discover installed agents and continue setup from their configuration page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={newAgent.name}
|
||||
onChange={(e) => setNewAgent({ ...newAgent, name: e.target.value })}
|
||||
placeholder="My Profile"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Local Agents Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agents detected on this machine are ready to configure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-type">Agent Type</Label>
|
||||
<Select
|
||||
value={newAgent.agent}
|
||||
onValueChange={(value) => handleAgentTypeChange(value as AgentType)}
|
||||
>
|
||||
<SelectTrigger id="agent-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGENT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<Select
|
||||
value={newAgent.model}
|
||||
onValueChange={(value) => setNewAgent({ ...newAgent, model: value })}
|
||||
>
|
||||
<SelectTrigger id="model">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODEL_OPTIONS[newAgent.agent].map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{installedAgents.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
No installed agents were detected yet.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{installedAgents.map((agent) => {
|
||||
const savedAgent = savedAgentsByName.get(agent.name);
|
||||
const configured = Boolean(savedAgent && savedAgent.profiles.length > 0);
|
||||
const hasAgentRecord = Boolean(savedAgent);
|
||||
return (
|
||||
<Card key={agent.name}>
|
||||
<CardContent className="py-4 flex flex-col gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{AGENT_LABELS[agent.name] ?? agent.name}</h4>
|
||||
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
|
||||
{configured && <Badge variant="outline">Configured</Badge>}
|
||||
</div>
|
||||
{agent.matched_path && (
|
||||
<p className="text-xs text-muted-foreground">Detected at {agent.matched_path}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="temperature">Temperature: {newAgent.temperature}</Label>
|
||||
<input
|
||||
id="temperature"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={newAgent.temperature}
|
||||
onChange={(e) =>
|
||||
setNewAgent({ ...newAgent, temperature: parseFloat(e.target.value) })
|
||||
}
|
||||
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Precise</span>
|
||||
<span>Creative</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border rounded-md">
|
||||
<div>
|
||||
<Label htmlFor="auto-approve" className="text-base font-medium">
|
||||
Auto-approve all actions
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically approve agent actions without prompting
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="auto-approve"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={newAgent.autoApprove}
|
||||
onClick={() =>
|
||||
setNewAgent({ ...newAgent, autoApprove: !newAgent.autoApprove })
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
newAgent.autoApprove ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
newAgent.autoApprove ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add Profile</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
/>
|
||||
))}
|
||||
|
||||
{agents.length === 0 && !isAdding && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No agent profiles configured. Add your first profile to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Button size="sm" className="cursor-pointer" asChild>
|
||||
<Link href={`/settings/agents/${encodeURIComponent(agent.name)}`}>
|
||||
<IconSettings className="h-4 w-4 mr-2" />
|
||||
{hasAgentRecord ? 'Create new profile' : 'Setup Profile'}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{savedAgents.some((agent) => agent.profiles.length > 0) && (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Agent Profiles</h3>
|
||||
<p className="text-sm text-muted-foreground">Manage existing profiles by agent.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{savedAgents.flatMap((agent) =>
|
||||
agent.profiles.map((profile) => {
|
||||
const profilePath = `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${profile.id}`;
|
||||
return (
|
||||
<Link key={profile.id} href={profilePath} className="block">
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||
<CardContent className="py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{AGENT_LABELS[agent.name] ?? agent.name}
|
||||
</span>
|
||||
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
|
||||
<span className="text-sm text-muted-foreground">{profile.name}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getBackendConfig } from '@/lib/config';
|
||||
import { listRepositories, listRepositoryBranches } from '@/lib/http/client';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useAppStoreApi } from '@/components/state-provider';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
|
||||
const AGENTS = [
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
@@ -48,6 +48,8 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
|
||||
// Use task from props but allow updates
|
||||
const task = initialTask;
|
||||
|
||||
// Track task state from store (kanban.tasks) to determine if agent is running
|
||||
useAppStore((state) => state.kanban.tasks.find((t) => t.id === task?.id));
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const agentLabel = AGENT_LABELS[agent.agent] || agent.agent;
|
||||
|
||||
return (
|
||||
<Link href={`/settings/agent/${agent.id}`}>
|
||||
<Link href={`/settings/agents/${agent.id}`}>
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
@@ -34,11 +34,14 @@ import { listEnvironmentsAction } from '@/app/actions/environments';
|
||||
import { listExecutorsAction } from '@/app/actions/executors';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { Environment, Executor } from '@/lib/types/http';
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import type { Agent } from '@/lib/types/http';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
|
||||
export function SettingsAppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const workspaces = useAppStore((state) => state.workspaces.items);
|
||||
const agentProfilesVersion = useAppStore((state) => state.agentProfiles.version);
|
||||
const fallbackEnvironments = React.useMemo<Environment[]>(
|
||||
() =>
|
||||
SETTINGS_DATA.environments.map((env) => ({
|
||||
@@ -74,8 +77,8 @@ export function SettingsAppSidebar() {
|
||||
[]
|
||||
);
|
||||
const [environments, setEnvironments] = React.useState<Environment[]>(fallbackEnvironments);
|
||||
const [agents] = React.useState(SETTINGS_DATA.agents);
|
||||
const [executors, setExecutors] = React.useState<Executor[]>(fallbackExecutors);
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const client = getWebSocketClient();
|
||||
@@ -194,6 +197,22 @@ export function SettingsAppSidebar() {
|
||||
unsubscribeExecutorDeleted();
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
const controller = new AbortController();
|
||||
fetch(`${apiBaseUrl}/api/v1/agents`, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.agents) {
|
||||
setAgents(data.agents);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => controller.abort();
|
||||
}, [agentProfilesVersion, pathname]);
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset">
|
||||
@@ -392,18 +411,24 @@ export function SettingsAppSidebar() {
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{agents.map((agent) => (
|
||||
<SidebarMenuSubItem key={agent.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === `/settings/agent/${agent.id}`}
|
||||
>
|
||||
<Link href={`/settings/agent/${agent.id}`}>
|
||||
<span>{agent.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
{agents.flatMap((agent) =>
|
||||
agent.profiles.map((profile) => {
|
||||
const encodedAgent = encodeURIComponent(agent.name);
|
||||
const profilePath = `/settings/agents/${encodedAgent}/profiles/${profile.id}`;
|
||||
return (
|
||||
<SidebarMenuSubItem key={profile.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === profilePath}
|
||||
>
|
||||
<Link href={profilePath}>
|
||||
<span>{profile.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -12,6 +13,11 @@ import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar';
|
||||
import { TooltipProvider } from '@kandev/ui/tooltip';
|
||||
|
||||
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isAgentDetail = pathname.startsWith('/settings/agents/') && pathname !== '/settings/agents';
|
||||
const breadcrumbLabel = isAgentDetail ? 'Agents' : 'Home';
|
||||
const breadcrumbHref = isAgentDetail ? '/settings/agents' : '/';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
@@ -22,9 +28,9 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/" className="flex items-center gap-2">
|
||||
<BreadcrumbLink href={breadcrumbHref} className="flex items-center gap-2">
|
||||
<IconArrowLeft className="h-4 w-4" />
|
||||
Home
|
||||
{breadcrumbLabel}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
|
||||
@@ -25,6 +25,21 @@ import type { Repository, Task } from '@/lib/types/http';
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import { createTask, listRepositories, listRepositoryBranches, updateTask } from '@/lib/http/client';
|
||||
|
||||
type AgentProfileOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type EnvironmentOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ExecutorOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface TaskCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -67,6 +82,15 @@ export function TaskCreateDialog({
|
||||
const [repositoriesLoading, setRepositoriesLoading] = useState(false);
|
||||
const [branchesByRepo, setBranchesByRepo] = useState<Record<string, string[]>>({});
|
||||
const [branchesLoading, setBranchesLoading] = useState(false);
|
||||
const [agentProfileId, setAgentProfileId] = useState('');
|
||||
const [agentProfiles, setAgentProfiles] = useState<AgentProfileOption[]>([]);
|
||||
const [agentProfilesLoading, setAgentProfilesLoading] = useState(false);
|
||||
const [environmentId, setEnvironmentId] = useState('');
|
||||
const [environments, setEnvironments] = useState<EnvironmentOption[]>([]);
|
||||
const [environmentsLoading, setEnvironmentsLoading] = useState(false);
|
||||
const [executorId, setExecutorId] = useState('');
|
||||
const [executors, setExecutors] = useState<ExecutorOption[]>([]);
|
||||
const [executorsLoading, setExecutorsLoading] = useState(false);
|
||||
const descriptionRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,6 +101,9 @@ export function TaskCreateDialog({
|
||||
setBranch(initialValues?.branch ?? '');
|
||||
setTaskState(initialValues?.state ?? editingTask?.state ?? 'CREATED');
|
||||
setStartAgent(false);
|
||||
setAgentProfileId('');
|
||||
setEnvironmentId('');
|
||||
setExecutorId('');
|
||||
}, [
|
||||
editingTask?.state,
|
||||
initialValues?.branch,
|
||||
@@ -121,6 +148,70 @@ export function TaskCreateDialog({
|
||||
});
|
||||
}, [branchesByRepo, repositoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAgentProfilesLoading(true);
|
||||
fetch(`${getBackendConfig().apiBaseUrl}/api/v1/agents`, { cache: 'no-store' })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (!data?.agents) {
|
||||
setAgentProfiles([]);
|
||||
return;
|
||||
}
|
||||
const options = data.agents.flatMap(
|
||||
(agent: { name: string; profiles: Array<{ id: string; name: string }> }) =>
|
||||
agent.profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: `${agent.name} • ${profile.name}`,
|
||||
}))
|
||||
);
|
||||
setAgentProfiles(options);
|
||||
})
|
||||
.catch(() => {
|
||||
setAgentProfiles([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setAgentProfilesLoading(false);
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const apiBaseUrl = getBackendConfig().apiBaseUrl;
|
||||
setEnvironmentsLoading(true);
|
||||
setExecutorsLoading(true);
|
||||
Promise.all([
|
||||
fetch(`${apiBaseUrl}/api/v1/environments`, { cache: 'no-store' })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (!data?.environments) return [];
|
||||
return data.environments.map((env: { id: string; name: string }) => ({
|
||||
id: env.id,
|
||||
name: env.name,
|
||||
}));
|
||||
})
|
||||
.catch(() => []),
|
||||
fetch(`${apiBaseUrl}/api/v1/executors`, { cache: 'no-store' })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (!data?.executors) return [];
|
||||
return data.executors.map((executor: { id: string; name: string }) => ({
|
||||
id: executor.id,
|
||||
name: executor.name,
|
||||
}));
|
||||
})
|
||||
.catch(() => []),
|
||||
])
|
||||
.then(([nextEnvironments, nextExecutors]) => {
|
||||
setEnvironments(nextEnvironments);
|
||||
setExecutors(nextExecutors);
|
||||
})
|
||||
.finally(() => {
|
||||
setEnvironmentsLoading(false);
|
||||
setExecutorsLoading(false);
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = descriptionRef.current;
|
||||
if (!textarea) return;
|
||||
@@ -147,6 +238,7 @@ export function TaskCreateDialog({
|
||||
setBranch('');
|
||||
setTaskState('CREATED');
|
||||
setStartAgent(false);
|
||||
setAgentProfileId('');
|
||||
onOpenChange(false);
|
||||
}
|
||||
return;
|
||||
@@ -174,6 +266,8 @@ export function TaskCreateDialog({
|
||||
repository_url: repository?.local_path ?? '',
|
||||
branch: branch || '',
|
||||
agent_type: startAgent ? 'default' : '',
|
||||
environment_id: environmentId || undefined,
|
||||
executor_id: executorId || undefined,
|
||||
state: targetState,
|
||||
});
|
||||
onSuccess?.(task, 'create');
|
||||
@@ -184,6 +278,9 @@ export function TaskCreateDialog({
|
||||
setBranch('');
|
||||
setTaskState('CREATED');
|
||||
setStartAgent(false);
|
||||
setAgentProfileId('');
|
||||
setEnvironmentId('');
|
||||
setExecutorId('');
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -195,6 +292,9 @@ export function TaskCreateDialog({
|
||||
setBranch('');
|
||||
setTaskState('CREATED');
|
||||
setStartAgent(false);
|
||||
setAgentProfileId('');
|
||||
setEnvironmentId('');
|
||||
setExecutorId('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -296,6 +396,77 @@ export function TaskCreateDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Agent Profile</Label>
|
||||
<Select value={agentProfileId} onValueChange={setAgentProfileId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
agentProfilesLoading
|
||||
? 'Loading agent profiles...'
|
||||
: agentProfiles.length === 0
|
||||
? 'No profiles available'
|
||||
: 'Select agent profile'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
{profile.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Environment</Label>
|
||||
<Select value={environmentId} onValueChange={setEnvironmentId} disabled={isEditMode}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
environmentsLoading
|
||||
? 'Loading environments...'
|
||||
: environments.length === 0
|
||||
? 'No environments available'
|
||||
: 'Select environment'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{environments.map((environment) => (
|
||||
<SelectItem key={environment.id} value={environment.id}>
|
||||
{environment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Executor</Label>
|
||||
<Select value={executorId} onValueChange={setExecutorId} disabled={isEditMode}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
executorsLoading
|
||||
? 'Loading executors...'
|
||||
: executors.length === 0
|
||||
? 'No executors available'
|
||||
: 'Select executor'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{executors.map((executor) => (
|
||||
<SelectItem key={executor.id} value={executor.id}>
|
||||
{executor.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="start-agent"
|
||||
|
||||
@@ -35,7 +35,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
}
|
||||
return Object.values(gitStatus.files);
|
||||
}, [gitStatus.files]);
|
||||
|
||||
const diffTargets = useMemo(
|
||||
() => (selectedDiffPath ? [selectedDiffPath] : changedFiles.map((file) => file.path)),
|
||||
[selectedDiffPath, changedFiles]
|
||||
|
||||
@@ -55,7 +55,7 @@ type ToolCallMetadata = {
|
||||
result?: string;
|
||||
};
|
||||
|
||||
function getToolIconElement(toolName?: string, className?: string) {
|
||||
function getToolIcon(toolName: string | undefined, className: string) {
|
||||
const name = toolName?.toLowerCase() ?? '';
|
||||
// Match ACP ToolKind values: read, edit, delete, move, search, execute
|
||||
if (name === 'edit' || name.includes('edit') || name.includes('replace') || name.includes('write') || name.includes('save')) {
|
||||
@@ -99,10 +99,7 @@ function ToolCallCard({ comment }: { comment: Comment }) {
|
||||
const args = metadata?.args;
|
||||
const result = metadata?.result;
|
||||
|
||||
const toolIcon = getToolIconElement(
|
||||
toolName,
|
||||
'h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0'
|
||||
);
|
||||
const toolIcon = getToolIcon(toolName, 'h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0');
|
||||
const hasDetails = args && Object.keys(args).length > 0;
|
||||
|
||||
// Extract file path from various possible sources
|
||||
|
||||
@@ -63,6 +63,8 @@ export async function createTask(
|
||||
repository_url?: string;
|
||||
branch?: string;
|
||||
agent_type?: string;
|
||||
environment_id?: string;
|
||||
executor_id?: string;
|
||||
state?: Task['state'];
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -34,6 +34,10 @@ export type AgentState = {
|
||||
agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>;
|
||||
};
|
||||
|
||||
export type AgentProfilesState = {
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type TerminalState = {
|
||||
terminals: Array<{ id: string; output: string[] }>;
|
||||
};
|
||||
@@ -83,6 +87,7 @@ export type AppState = {
|
||||
boards: BoardState;
|
||||
tasks: TaskState;
|
||||
agents: AgentState;
|
||||
agentProfiles: AgentProfilesState;
|
||||
terminal: TerminalState;
|
||||
diffs: DiffState;
|
||||
gitStatus: GitStatusState;
|
||||
@@ -101,6 +106,7 @@ export type AppState = {
|
||||
setCommentsLoading: (loading: boolean) => void;
|
||||
setGitStatus: (taskId: string, gitStatus: Omit<GitStatusState, 'taskId'>) => void;
|
||||
clearGitStatus: () => void;
|
||||
bumpAgentProfilesVersion: () => void;
|
||||
};
|
||||
|
||||
export type AppStore = ReturnType<typeof createAppStore>;
|
||||
@@ -111,6 +117,7 @@ const defaultState: AppState = {
|
||||
boards: { items: [], activeId: null },
|
||||
tasks: { activeTaskId: null },
|
||||
agents: { agents: [] },
|
||||
agentProfiles: { version: 0 },
|
||||
terminal: { terminals: [] },
|
||||
diffs: { files: [] },
|
||||
gitStatus: {
|
||||
@@ -142,9 +149,28 @@ const defaultState: AppState = {
|
||||
setCommentsLoading: () => undefined,
|
||||
setGitStatus: () => undefined,
|
||||
clearGitStatus: () => undefined,
|
||||
bumpAgentProfilesVersion: () => undefined,
|
||||
};
|
||||
|
||||
function mergeInitialState(initialState?: Partial<AppState>): Omit<AppState, 'hydrate' | 'setActiveWorkspace' | 'setWorkspaces' | 'setActiveBoard' | 'setBoards' | 'setTerminalOutput' | 'setConnectionStatus' | 'setComments' | 'setCommentsTaskId' | 'addComment' | 'setCommentsLoading' | 'setGitStatus' | 'clearGitStatus'> {
|
||||
function mergeInitialState(
|
||||
initialState?: Partial<AppState>
|
||||
): Omit<
|
||||
AppState,
|
||||
| 'hydrate'
|
||||
| 'setActiveWorkspace'
|
||||
| 'setWorkspaces'
|
||||
| 'setActiveBoard'
|
||||
| 'setBoards'
|
||||
| 'setTerminalOutput'
|
||||
| 'setConnectionStatus'
|
||||
| 'setComments'
|
||||
| 'setCommentsTaskId'
|
||||
| 'addComment'
|
||||
| 'setCommentsLoading'
|
||||
| 'setGitStatus'
|
||||
| 'clearGitStatus'
|
||||
| 'bumpAgentProfilesVersion'
|
||||
> {
|
||||
if (!initialState) return defaultState;
|
||||
return {
|
||||
workspaces: { ...defaultState.workspaces, ...initialState.workspaces },
|
||||
@@ -152,6 +178,7 @@ function mergeInitialState(initialState?: Partial<AppState>): Omit<AppState, 'hy
|
||||
kanban: { ...defaultState.kanban, ...initialState.kanban },
|
||||
tasks: { ...defaultState.tasks, ...initialState.tasks },
|
||||
agents: { ...defaultState.agents, ...initialState.agents },
|
||||
agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles },
|
||||
terminal: { ...defaultState.terminal, ...initialState.terminal },
|
||||
diffs: { ...defaultState.diffs, ...initialState.diffs },
|
||||
gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus },
|
||||
@@ -173,6 +200,7 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
if (state.kanban) Object.assign(draft.kanban, state.kanban);
|
||||
if (state.tasks) Object.assign(draft.tasks, state.tasks);
|
||||
if (state.agents) Object.assign(draft.agents, state.agents);
|
||||
if (state.agentProfiles) Object.assign(draft.agentProfiles, state.agentProfiles);
|
||||
if (state.terminal) Object.assign(draft.terminal, state.terminal);
|
||||
if (state.diffs) Object.assign(draft.diffs, state.diffs);
|
||||
if (state.connection) Object.assign(draft.connection, state.connection);
|
||||
@@ -272,6 +300,10 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
timestamp: null,
|
||||
};
|
||||
}),
|
||||
bumpAgentProfilesVersion: () =>
|
||||
set((draft) => {
|
||||
draft.agentProfiles.version += 1;
|
||||
}),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ export type BackendMessageType =
|
||||
| 'executor.deleted'
|
||||
| 'environment.created'
|
||||
| 'environment.updated'
|
||||
| 'environment.deleted';
|
||||
| 'environment.deleted'
|
||||
| 'agent.profile.deleted'
|
||||
| 'agent.profile.created'
|
||||
| 'agent.profile.updated';
|
||||
|
||||
export type BackendMessage<T extends BackendMessageType, P> = {
|
||||
id?: string;
|
||||
@@ -175,6 +178,15 @@ export type EnvironmentPayload = {
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type AgentProfileDeletedPayload = {
|
||||
profile_id: string;
|
||||
};
|
||||
|
||||
export type AgentProfileChangedPayload = {
|
||||
profile_id: string;
|
||||
agent_id: string;
|
||||
};
|
||||
|
||||
export type BackendMessageMap = {
|
||||
'kanban.update': BackendMessage<'kanban.update', KanbanUpdatePayload>;
|
||||
'task.created': BackendMessage<'task.created', TaskEventPayload>;
|
||||
@@ -202,4 +214,7 @@ export type BackendMessageMap = {
|
||||
'environment.created': BackendMessage<'environment.created', EnvironmentPayload>;
|
||||
'environment.updated': BackendMessage<'environment.updated', EnvironmentPayload>;
|
||||
'environment.deleted': BackendMessage<'environment.deleted', EnvironmentPayload>;
|
||||
'agent.profile.deleted': BackendMessage<'agent.profile.deleted', AgentProfileDeletedPayload>;
|
||||
'agent.profile.created': BackendMessage<'agent.profile.created', AgentProfileChangedPayload>;
|
||||
'agent.profile.updated': BackendMessage<'agent.profile.updated', AgentProfileChangedPayload>;
|
||||
};
|
||||
|
||||
@@ -193,3 +193,45 @@ export type Comment = {
|
||||
requests_input?: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AgentProfile = {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
auto_approve: boolean;
|
||||
dangerously_skip_permissions: boolean;
|
||||
plan: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Agent = {
|
||||
id: string;
|
||||
name: string;
|
||||
workspace_id?: string | null;
|
||||
supports_mcp: boolean;
|
||||
mcp_config_path?: string | null;
|
||||
profiles: AgentProfile[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AgentDiscovery = {
|
||||
name: string;
|
||||
supports_mcp: boolean;
|
||||
mcp_config_path?: string | null;
|
||||
installation_paths: string[];
|
||||
available: boolean;
|
||||
matched_path?: string | null;
|
||||
};
|
||||
|
||||
export type ListAgentsResponse = {
|
||||
agents: Agent[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListAgentDiscoveryResponse = {
|
||||
agents: AgentDiscovery[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
@@ -378,5 +378,14 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
|
||||
timestamp: payload.timestamp,
|
||||
});
|
||||
},
|
||||
'agent.profile.deleted': () => {
|
||||
store.getState().bumpAgentProfilesVersion();
|
||||
},
|
||||
'agent.profile.created': () => {
|
||||
store.getState().bumpAgentProfilesVersion();
|
||||
},
|
||||
'agent.profile.updated': () => {
|
||||
store.getState().bumpAgentProfilesVersion();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user