feat: notifications (#24)
This commit is contained in:
@@ -41,6 +41,10 @@ import (
|
||||
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"
|
||||
notificationcontroller "github.com/kandev/kandev/internal/notifications/controller"
|
||||
notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers"
|
||||
notificationservice "github.com/kandev/kandev/internal/notifications/service"
|
||||
notificationstore "github.com/kandev/kandev/internal/notifications/store"
|
||||
|
||||
// Orchestrator packages
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
@@ -210,6 +214,14 @@ func main() {
|
||||
_ = userRepo.Close()
|
||||
}()
|
||||
|
||||
notificationRepo, err := notificationstore.NewSQLiteRepository(dbPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize notification store", zap.Error(err), zap.String("db_path", dbPath))
|
||||
}
|
||||
defer func() {
|
||||
_ = notificationRepo.Close()
|
||||
}()
|
||||
|
||||
discoveryRegistry, err := discovery.LoadRegistry()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load agent discovery config", zap.Error(err))
|
||||
@@ -218,6 +230,8 @@ func main() {
|
||||
|
||||
userSvc := userservice.NewService(userRepo, eventBus, log)
|
||||
userCtrl := usercontroller.NewController(userSvc)
|
||||
var notificationSvc *notificationservice.Service
|
||||
var notificationCtrl *notificationcontroller.Controller
|
||||
|
||||
taskSvc := taskservice.NewService(
|
||||
taskRepo,
|
||||
@@ -462,6 +476,22 @@ func main() {
|
||||
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
|
||||
gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log)
|
||||
|
||||
notificationSvc = notificationservice.NewService(notificationRepo, taskRepo, gateway.Hub, log)
|
||||
notificationCtrl = notificationcontroller.NewController(notificationSvc)
|
||||
if eventBus != nil {
|
||||
_, err = eventBus.Subscribe(events.TaskSessionStateChanged, func(ctx context.Context, event *bus.Event) error {
|
||||
data := event.Data
|
||||
taskID, _ := data["task_id"].(string)
|
||||
sessionID, _ := data["task_session_id"].(string)
|
||||
newState, _ := data["new_state"].(string)
|
||||
notificationSvc.HandleTaskSessionStateChanged(ctx, taskID, sessionID, newState)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Failed to subscribe to task session notifications", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Set up historical logs provider for task subscriptions
|
||||
// Uses messages instead of execution logs - all agent messages are now messages
|
||||
gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) {
|
||||
@@ -781,6 +811,9 @@ func main() {
|
||||
userhandlers.RegisterRoutes(router, gateway.Dispatcher, userCtrl, log)
|
||||
log.Info("Registered User handlers (HTTP + WebSocket)")
|
||||
|
||||
notificationhandlers.RegisterRoutes(router, notificationCtrl, log)
|
||||
log.Info("Registered Notification 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,94 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/notifications/dto"
|
||||
"github.com/kandev/kandev/internal/notifications/models"
|
||||
"github.com/kandev/kandev/internal/notifications/service"
|
||||
userstore "github.com/kandev/kandev/internal/user/store"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
service *service.Service
|
||||
}
|
||||
|
||||
func NewController(svc *service.Service) *Controller {
|
||||
return &Controller{service: svc}
|
||||
}
|
||||
|
||||
func (c *Controller) ListProviders(ctx context.Context) (dto.NotificationProvidersResponse, error) {
|
||||
userID := userstore.DefaultUserID
|
||||
providers, subscriptions, err := c.service.ListProviders(ctx, userID)
|
||||
if err != nil {
|
||||
return dto.NotificationProvidersResponse{}, err
|
||||
}
|
||||
result := make([]dto.NotificationProviderDTO, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
events := subscriptions[provider.ID]
|
||||
result = append(result, dto.FromProvider(provider, events))
|
||||
}
|
||||
return dto.NotificationProvidersResponse{
|
||||
Providers: result,
|
||||
AppriseAvailable: c.service.AppriseAvailable(),
|
||||
Events: c.service.AvailableEvents(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) CreateProvider(ctx context.Context, req dto.UpsertProviderRequest) (dto.NotificationProviderDTO, error) {
|
||||
userID := userstore.DefaultUserID
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = defaultNameForType(req.Type)
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
providerType := models.ProviderType(req.Type)
|
||||
config := req.Config
|
||||
provider, err := c.service.CreateProvider(ctx, userID, name, providerType, config, enabled, req.Events)
|
||||
if err != nil {
|
||||
return dto.NotificationProviderDTO{}, err
|
||||
}
|
||||
return dto.FromProvider(provider, req.Events), nil
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateProvider(ctx context.Context, providerID string, req dto.UpdateProviderRequest) (dto.NotificationProviderDTO, error) {
|
||||
var providerType *models.ProviderType
|
||||
if req.Type != nil {
|
||||
t := models.ProviderType(*req.Type)
|
||||
providerType = &t
|
||||
}
|
||||
updates := service.ProviderUpdate{
|
||||
Name: req.Name,
|
||||
Enabled: req.Enabled,
|
||||
Type: providerType,
|
||||
Config: req.Config,
|
||||
Events: req.Events,
|
||||
}
|
||||
provider, err := c.service.UpdateProvider(ctx, providerID, updates)
|
||||
if err != nil {
|
||||
return dto.NotificationProviderDTO{}, err
|
||||
}
|
||||
userID := userstore.DefaultUserID
|
||||
_, subscriptions, err := c.service.ListProviders(ctx, userID)
|
||||
if err != nil {
|
||||
return dto.NotificationProviderDTO{}, err
|
||||
}
|
||||
return dto.FromProvider(provider, subscriptions[provider.ID]), nil
|
||||
}
|
||||
|
||||
func (c *Controller) DeleteProvider(ctx context.Context, providerID string) error {
|
||||
return c.service.DeleteProvider(ctx, providerID)
|
||||
}
|
||||
|
||||
func defaultNameForType(providerType string) string {
|
||||
switch providerType {
|
||||
case string(models.ProviderTypeApprise):
|
||||
return "Apprise"
|
||||
default:
|
||||
return "Notifications"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/notifications/models"
|
||||
)
|
||||
|
||||
type NotificationProviderDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Events []string `json:"events"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type NotificationProvidersResponse struct {
|
||||
Providers []NotificationProviderDTO `json:"providers"`
|
||||
AppriseAvailable bool `json:"apprise_available"`
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
type UpsertProviderRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Events []string `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateProviderRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Events *[]string `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
func FromProvider(provider *models.Provider, events []string) NotificationProviderDTO {
|
||||
return NotificationProviderDTO{
|
||||
ID: provider.ID,
|
||||
Name: provider.Name,
|
||||
Type: string(provider.Type),
|
||||
Config: provider.Config,
|
||||
Enabled: provider.Enabled,
|
||||
Events: events,
|
||||
CreatedAt: provider.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: provider.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/notifications/controller"
|
||||
"github.com/kandev/kandev/internal/notifications/dto"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
controller *controller.Controller
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, log *logger.Logger) {
|
||||
h := &Handlers{
|
||||
controller: ctrl,
|
||||
logger: log.WithFields(zap.String("component", "notification-handlers")),
|
||||
}
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/notification-providers", h.httpListProviders)
|
||||
api.POST("/notification-providers", h.httpCreateProvider)
|
||||
api.PATCH("/notification-providers/:id", h.httpUpdateProvider)
|
||||
api.DELETE("/notification-providers/:id", h.httpDeleteProvider)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpListProviders(c *gin.Context) {
|
||||
resp, err := h.controller.ListProviders(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list notification providers", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notification providers"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpCreateProvider(c *gin.Context) {
|
||||
var body dto.UpsertProviderRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.CreateProvider(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create notification provider", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpUpdateProvider(c *gin.Context) {
|
||||
providerID := c.Param("id")
|
||||
var body dto.UpdateProviderRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.UpdateProvider(c.Request.Context(), providerID, body)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update notification provider", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpDeleteProvider(c *gin.Context) {
|
||||
providerID := c.Param("id")
|
||||
if err := h.controller.DeleteProvider(c.Request.Context(), providerID); err != nil {
|
||||
h.logger.Error("failed to delete notification provider", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notification provider"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderTypeLocal ProviderType = "local"
|
||||
ProviderTypeApprise ProviderType = "apprise"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type ProviderType `json:"type"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Delivery struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
EventType string `json:"event_type"`
|
||||
TaskSessionID string `json:"task_session_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AppriseProvider struct{}
|
||||
|
||||
func NewAppriseProvider() *AppriseProvider {
|
||||
return &AppriseProvider{}
|
||||
}
|
||||
|
||||
func (p *AppriseProvider) Available() bool {
|
||||
_, err := exec.LookPath("apprise")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (p *AppriseProvider) Validate(config map[string]interface{}) error {
|
||||
_, err := parseAppriseURLs(config)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *AppriseProvider) Send(ctx context.Context, message Message) error {
|
||||
if !p.Available() {
|
||||
return fmt.Errorf("apprise not installed")
|
||||
}
|
||||
urls, err := parseAppriseURLs(message.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return fmt.Errorf("apprise urls not configured")
|
||||
}
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
args := []string{"-t", message.Title, "-b", message.Body}
|
||||
args = append(args, urls...)
|
||||
cmd := exec.CommandContext(timeoutCtx, "apprise", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise failed: %w (%s)", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAppriseURLs(config map[string]interface{}) ([]string, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("apprise config missing")
|
||||
}
|
||||
raw, ok := config["urls"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("apprise urls missing")
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case []string:
|
||||
return value, nil
|
||||
case []interface{}:
|
||||
urls := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
text, ok := item.(string)
|
||||
if ok && strings.TrimSpace(text) != "" {
|
||||
urls = append(urls, strings.TrimSpace(text))
|
||||
}
|
||||
}
|
||||
return urls, nil
|
||||
case string:
|
||||
parts := strings.Split(value, "\n")
|
||||
var urls []string
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
urls = append(urls, part)
|
||||
}
|
||||
}
|
||||
return urls, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("apprise urls must be a list of strings")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
gatewayws "github.com/kandev/kandev/internal/gateway/websocket"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
type LocalProvider struct {
|
||||
hub *gatewayws.Hub
|
||||
}
|
||||
|
||||
func NewLocalProvider(hub *gatewayws.Hub) *LocalProvider {
|
||||
return &LocalProvider{hub: hub}
|
||||
}
|
||||
|
||||
func (p *LocalProvider) Available() bool {
|
||||
return p.hub != nil
|
||||
}
|
||||
|
||||
func (p *LocalProvider) Validate(_ map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LocalProvider) Send(_ context.Context, message Message) error {
|
||||
if p.hub == nil {
|
||||
return fmt.Errorf("websocket hub not available")
|
||||
}
|
||||
msg, err := ws.NewNotification(ws.ActionTaskSessionWaitingForInput, map[string]interface{}{
|
||||
"task_id": message.TaskID,
|
||||
"task_session_id": message.TaskSessionID,
|
||||
"title": message.Title,
|
||||
"body": message.Body,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.hub.BroadcastToUser(message.UserID, msg)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
EventType string
|
||||
Title string
|
||||
Body string
|
||||
TaskID string
|
||||
TaskSessionID string
|
||||
UserID string
|
||||
Config map[string]interface{}
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Available() bool
|
||||
Validate(config map[string]interface{}) error
|
||||
Send(ctx context.Context, message Message) error
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
gatewayws "github.com/kandev/kandev/internal/gateway/websocket"
|
||||
"github.com/kandev/kandev/internal/notifications/models"
|
||||
"github.com/kandev/kandev/internal/notifications/providers"
|
||||
notificationstore "github.com/kandev/kandev/internal/notifications/store"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
userstore "github.com/kandev/kandev/internal/user/store"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
EventTaskSessionWaitingForInput = "task_session.waiting_for_input"
|
||||
)
|
||||
|
||||
var ErrProviderNotFound = errors.New("notification provider not found")
|
||||
|
||||
type Service struct {
|
||||
repo notificationstore.Repository
|
||||
taskRepo repository.Repository
|
||||
hub *gatewayws.Hub
|
||||
logger *logger.Logger
|
||||
providers map[models.ProviderType]providers.Provider
|
||||
}
|
||||
|
||||
func NewService(repo notificationstore.Repository, taskRepo repository.Repository, hub *gatewayws.Hub, log *logger.Logger) *Service {
|
||||
providerMap := map[models.ProviderType]providers.Provider{
|
||||
models.ProviderTypeLocal: providers.NewLocalProvider(hub),
|
||||
models.ProviderTypeApprise: providers.NewAppriseProvider(),
|
||||
}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
taskRepo: taskRepo,
|
||||
hub: hub,
|
||||
logger: log.WithFields(zap.String("component", "notifications-service")),
|
||||
providers: providerMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) AppriseAvailable() bool {
|
||||
provider := s.providers[models.ProviderTypeApprise]
|
||||
if provider == nil {
|
||||
return false
|
||||
}
|
||||
return provider.Available()
|
||||
}
|
||||
|
||||
func (s *Service) AvailableEvents() []string {
|
||||
return []string{EventTaskSessionWaitingForInput}
|
||||
}
|
||||
|
||||
func (s *Service) ListProviders(ctx context.Context, userID string) ([]*models.Provider, map[string][]string, error) {
|
||||
if err := s.ensureDefaultLocalProvider(ctx, userID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
providers, err := s.repo.ListProvidersByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
subscriptions := make(map[string][]string, len(providers))
|
||||
for _, provider := range providers {
|
||||
subs, err := s.repo.ListSubscriptionsByProvider(ctx, provider.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if sub.Enabled {
|
||||
subscriptions[provider.ID] = append(subscriptions[provider.ID], sub.EventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
return providers, subscriptions, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateProvider(ctx context.Context, userID, name string, providerType models.ProviderType, config map[string]interface{}, enabled bool, events []string) (*models.Provider, error) {
|
||||
if err := s.validateProvider(providerType, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateEvents(events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provider := &models.Provider{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Type: providerType,
|
||||
Config: config,
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := s.repo.CreateProvider(ctx, provider); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProvider(ctx context.Context, providerID string, updates ProviderUpdate) (*models.Provider, error) {
|
||||
provider, err := s.repo.GetProvider(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, ErrProviderNotFound
|
||||
}
|
||||
if updates.Name != nil {
|
||||
provider.Name = strings.TrimSpace(*updates.Name)
|
||||
}
|
||||
if updates.Enabled != nil {
|
||||
provider.Enabled = *updates.Enabled
|
||||
}
|
||||
if updates.Config != nil {
|
||||
provider.Config = updates.Config
|
||||
}
|
||||
if updates.Type != nil {
|
||||
provider.Type = *updates.Type
|
||||
}
|
||||
if err := s.validateProvider(provider.Type, provider.Config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.UpdateProvider(ctx, provider); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updates.Events != nil {
|
||||
if err := s.validateEvents(*updates.Events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.ReplaceSubscriptions(ctx, provider.ID, provider.UserID, *updates.Events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteProvider(ctx context.Context, providerID string) error {
|
||||
return s.repo.DeleteProvider(ctx, providerID)
|
||||
}
|
||||
|
||||
type ProviderUpdate struct {
|
||||
Name *string
|
||||
Enabled *bool
|
||||
Type *models.ProviderType
|
||||
Config map[string]interface{}
|
||||
Events *[]string
|
||||
}
|
||||
|
||||
func (s *Service) HandleTaskSessionStateChanged(ctx context.Context, taskID, sessionID, newState string) {
|
||||
if newState != "WAITING_FOR_INPUT" {
|
||||
return
|
||||
}
|
||||
userID := userstore.DefaultUserID
|
||||
providers, subscriptions, err := s.ListProviders(ctx, userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to load notification providers", zap.Error(err))
|
||||
return
|
||||
}
|
||||
title, body := s.buildWaitingForInputMessage(ctx, taskID)
|
||||
for _, provider := range providers {
|
||||
if !provider.Enabled {
|
||||
continue
|
||||
}
|
||||
events := subscriptions[provider.ID]
|
||||
if !containsEvent(events, EventTaskSessionWaitingForInput) {
|
||||
continue
|
||||
}
|
||||
delivery := &models.Delivery{
|
||||
UserID: userID,
|
||||
ProviderID: provider.ID,
|
||||
EventType: EventTaskSessionWaitingForInput,
|
||||
TaskSessionID: sessionID,
|
||||
}
|
||||
inserted, err := s.repo.InsertDelivery(ctx, delivery)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to record notification delivery", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if !inserted {
|
||||
continue
|
||||
}
|
||||
if err := s.dispatchProvider(ctx, provider, waitingForInputPayload{
|
||||
TaskID: taskID,
|
||||
TaskSessionID: sessionID,
|
||||
Title: title,
|
||||
Body: body,
|
||||
}); err != nil {
|
||||
s.logger.Warn("notification delivery failed", zap.String("provider_id", provider.ID), zap.Error(err))
|
||||
_ = s.repo.DeleteDelivery(ctx, provider.ID, EventTaskSessionWaitingForInput, sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type waitingForInputPayload struct {
|
||||
TaskID string
|
||||
TaskSessionID string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (s *Service) dispatchProvider(ctx context.Context, provider *models.Provider, payload waitingForInputPayload) error {
|
||||
adapter := s.providers[provider.Type]
|
||||
if adapter == nil {
|
||||
return fmt.Errorf("unknown provider type: %s", provider.Type)
|
||||
}
|
||||
return adapter.Send(ctx, providers.Message{
|
||||
EventType: EventTaskSessionWaitingForInput,
|
||||
Title: payload.Title,
|
||||
Body: payload.Body,
|
||||
TaskID: payload.TaskID,
|
||||
TaskSessionID: payload.TaskSessionID,
|
||||
UserID: userstore.DefaultUserID,
|
||||
Config: provider.Config,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) buildWaitingForInputMessage(ctx context.Context, taskID string) (string, string) {
|
||||
title := "Task needs your input"
|
||||
body := "An agent is waiting for your input."
|
||||
if taskID == "" || s.taskRepo == nil {
|
||||
return title, body
|
||||
}
|
||||
task, err := s.taskRepo.GetTask(ctx, taskID)
|
||||
if err != nil || task == nil {
|
||||
return title, body
|
||||
}
|
||||
if task.Title != "" {
|
||||
body = fmt.Sprintf("An agent is waiting for your input on \"%s\".", task.Title)
|
||||
}
|
||||
return title, body
|
||||
}
|
||||
|
||||
func (s *Service) ensureDefaultLocalProvider(ctx context.Context, userID string) error {
|
||||
providers, err := s.repo.ListProvidersByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, provider := range providers {
|
||||
if provider.Type == models.ProviderTypeLocal {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
provider := &models.Provider{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: "Desktop Notifications",
|
||||
Type: models.ProviderTypeLocal,
|
||||
Config: map[string]interface{}{},
|
||||
Enabled: true,
|
||||
}
|
||||
if err := s.repo.CreateProvider(ctx, provider); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{EventTaskSessionWaitingForInput})
|
||||
}
|
||||
|
||||
func (s *Service) validateProvider(providerType models.ProviderType, config map[string]interface{}) error {
|
||||
adapter := s.providers[providerType]
|
||||
if adapter == nil {
|
||||
return fmt.Errorf("unsupported provider type: %s", providerType)
|
||||
}
|
||||
return adapter.Validate(config)
|
||||
}
|
||||
|
||||
func (s *Service) validateEvents(events []string) error {
|
||||
allowed := map[string]struct{}{
|
||||
EventTaskSessionWaitingForInput: {},
|
||||
}
|
||||
for _, event := range events {
|
||||
if _, ok := allowed[event]; !ok {
|
||||
return fmt.Errorf("unsupported event type: %s", event)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsEvent(events []string, target string) bool {
|
||||
for _, event := range events {
|
||||
if event == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/notifications/models"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CreateProvider(ctx context.Context, provider *models.Provider) error
|
||||
UpdateProvider(ctx context.Context, provider *models.Provider) error
|
||||
GetProvider(ctx context.Context, id string) (*models.Provider, error)
|
||||
ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error)
|
||||
DeleteProvider(ctx context.Context, id string) error
|
||||
|
||||
ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error)
|
||||
ReplaceSubscriptions(ctx context.Context, providerID, userID string, events []string) error
|
||||
|
||||
InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error)
|
||||
DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error
|
||||
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/notifications/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 {
|
||||
if closeErr := db.Close(); closeErr != nil {
|
||||
return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) Close() error {
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
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 notification_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE(provider_id, event_type),
|
||||
FOREIGN KEY (provider_id) REFERENCES notification_providers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
task_session_id TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
UNIQUE(provider_id, event_type, task_session_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_providers_user_id ON notification_providers(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_provider_id ON notification_subscriptions(provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_user_id ON notification_subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_session_id ON notification_deliveries(task_session_id);
|
||||
`
|
||||
|
||||
_, err := r.db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) CreateProvider(ctx context.Context, provider *models.Provider) error {
|
||||
provider.ID = uuid.New().String()
|
||||
now := time.Now().UTC()
|
||||
provider.CreatedAt = now
|
||||
provider.UpdatedAt = now
|
||||
if provider.Config == nil {
|
||||
provider.Config = map[string]interface{}{}
|
||||
}
|
||||
configJSON, err := json.Marshal(provider.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize provider config: %w", err)
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) UpdateProvider(ctx context.Context, provider *models.Provider) error {
|
||||
provider.UpdatedAt = time.Now().UTC()
|
||||
if provider.Config == nil {
|
||||
provider.Config = map[string]interface{}{}
|
||||
}
|
||||
configJSON, err := json.Marshal(provider.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize provider config: %w", err)
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
UPDATE notification_providers
|
||||
SET name = ?, type = ?, config = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) GetProvider(ctx context.Context, id string) (*models.Provider, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, name, type, config, enabled, created_at, updated_at
|
||||
FROM notification_providers
|
||||
WHERE id = ?
|
||||
`, id)
|
||||
return scanProvider(row)
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, user_id, name, type, config, enabled, created_at, updated_at
|
||||
FROM notification_providers
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
|
||||
var providers []*models.Provider
|
||||
for rows.Next() {
|
||||
provider, err := scanProvider(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) DeleteProvider(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM notification_providers WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, user_id, provider_id, event_type, enabled, created_at, updated_at
|
||||
FROM notification_subscriptions
|
||||
WHERE provider_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`, providerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
|
||||
var subs []*models.Subscription
|
||||
for rows.Next() {
|
||||
sub, err := scanSubscription(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) ReplaceSubscriptions(ctx context.Context, providerID, userID string, events []string) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM notification_subscriptions WHERE provider_id = ?`, providerID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, eventType := range events {
|
||||
subID := uuid.New().String()
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, subID, userID, providerID, eventType, boolToInt(true), now, now); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error) {
|
||||
delivery.ID = uuid.New().String()
|
||||
delivery.CreatedAt = time.Now().UTC()
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO notification_deliveries (id, user_id, provider_id, event_type, task_session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, delivery.ID, delivery.UserID, delivery.ProviderID, delivery.EventType, delivery.TaskSessionID, delivery.CreatedAt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteRepository) DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM notification_deliveries
|
||||
WHERE provider_id = ? AND event_type = ? AND task_session_id = ?
|
||||
`, providerID, eventType, taskSessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanProvider(scanner interface{ Scan(dest ...any) error }) (*models.Provider, error) {
|
||||
provider := &models.Provider{}
|
||||
var configJSON string
|
||||
var enabledInt int
|
||||
if err := scanner.Scan(
|
||||
&provider.ID,
|
||||
&provider.UserID,
|
||||
&provider.Name,
|
||||
&provider.Type,
|
||||
&configJSON,
|
||||
&enabledInt,
|
||||
&provider.CreatedAt,
|
||||
&provider.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provider.Enabled = enabledInt == 1
|
||||
if configJSON != "" && configJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(configJSON), &provider.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize provider config: %w", err)
|
||||
}
|
||||
} else {
|
||||
provider.Config = map[string]interface{}{}
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*models.Subscription, error) {
|
||||
sub := &models.Subscription{}
|
||||
var enabledInt int
|
||||
if err := scanner.Scan(
|
||||
&sub.ID,
|
||||
&sub.UserID,
|
||||
&sub.ProviderID,
|
||||
&sub.EventType,
|
||||
&enabledInt,
|
||||
&sub.CreatedAt,
|
||||
&sub.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.Enabled = enabledInt == 1
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -124,6 +124,7 @@ const (
|
||||
ActionMessageAdded = "message.added"
|
||||
ActionMessageUpdated = "message.updated"
|
||||
ActionTaskSessionStateChanged = "task_session.state_changed"
|
||||
ActionTaskSessionWaitingForInput = "task_session.waiting_for_input"
|
||||
ActionInputRequested = "input.requested"
|
||||
ActionRepositoryCreated = "repository.created"
|
||||
ActionRepositoryUpdated = "repository.updated"
|
||||
|
||||
@@ -16,7 +16,8 @@ export type BackendMessageType =
|
||||
| 'board.deleted'
|
||||
| 'column.created'
|
||||
| 'column.updated'
|
||||
| 'column.deleted';
|
||||
| 'column.deleted'
|
||||
| 'task_session.waiting_for_input';
|
||||
|
||||
export type BackendMessage<T extends BackendMessageType, P> = {
|
||||
id?: string;
|
||||
@@ -77,6 +78,13 @@ export type SystemErrorPayload = {
|
||||
code?: string;
|
||||
};
|
||||
|
||||
export type TaskSessionWaitingForInputPayload = {
|
||||
task_id: string;
|
||||
task_session_id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type WorkspacePayload = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -125,4 +133,8 @@ export type BackendMessageMap = {
|
||||
'column.created': BackendMessage<'column.created', ColumnPayload>;
|
||||
'column.updated': BackendMessage<'column.updated', ColumnPayload>;
|
||||
'column.deleted': BackendMessage<'column.deleted', ColumnPayload>;
|
||||
'task_session.waiting_for_input': BackendMessage<
|
||||
'task_session.waiting_for_input',
|
||||
TaskSessionWaitingForInputPayload
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NotificationsSettings } from '@/components/settings/notifications-settings';
|
||||
import { listNotificationProviders } from '@/lib/http';
|
||||
import type { NotificationProvider } from '@/lib/types/http';
|
||||
|
||||
export default async function GeneralNotificationsPage() {
|
||||
let providers: NotificationProvider[] | undefined = [];
|
||||
let events: string[] = [];
|
||||
let appriseAvailable = true;
|
||||
try {
|
||||
const response = await listNotificationProviders({ cache: 'no-store' });
|
||||
providers = response.providers ?? [];
|
||||
events = response.events ?? [];
|
||||
appriseAvailable = response.apprise_available ?? true;
|
||||
} catch {
|
||||
providers = undefined;
|
||||
events = [];
|
||||
appriseAvailable = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationsSettings
|
||||
initialProviders={providers}
|
||||
initialEvents={events}
|
||||
initialAppriseAvailable={appriseAvailable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { IconPalette, IconCode, IconBell, IconServer } from '@tabler/icons-react';
|
||||
import { IconPalette, IconCode, IconServer } from '@tabler/icons-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
@@ -11,7 +11,7 @@ import { Separator } from '@kandev/ui/separator';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import type { Theme, Editor, Notifications } from '@/lib/settings/types';
|
||||
import type { Theme, Editor } from '@/lib/settings/types';
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { theme: currentTheme, setTheme } = useTheme();
|
||||
@@ -19,9 +19,6 @@ export default function GeneralSettingsPage() {
|
||||
const [customEditorCommand, setCustomEditorCommand] = useState<string>(
|
||||
SETTINGS_DATA.general.customEditorCommand || ''
|
||||
);
|
||||
const [notifications, setNotifications] = useState<Notifications>(
|
||||
SETTINGS_DATA.general.notifications
|
||||
);
|
||||
const [backendUrl] = useState<string>(() => getBackendConfig().apiBaseUrl);
|
||||
const mounted = useSyncExternalStore(
|
||||
() => () => undefined,
|
||||
@@ -29,10 +26,6 @@ export default function GeneralSettingsPage() {
|
||||
() => false
|
||||
);
|
||||
|
||||
const handleNotificationToggle = (key: keyof Notifications) => {
|
||||
setNotifications((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const displayBackendUrl = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
|
||||
if (!mounted) {
|
||||
@@ -128,102 +121,6 @@ export default function GeneralSettingsPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconBell className="h-5 w-5" />}
|
||||
title="Notifications"
|
||||
description="Control what notifications you receive"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="task-updates" className="text-base font-medium">
|
||||
Task Updates
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when tasks change status
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="task-updates"
|
||||
role="switch"
|
||||
aria-checked={notifications.taskUpdates}
|
||||
onClick={() => handleNotificationToggle('taskUpdates')}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
notifications.taskUpdates ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
notifications.taskUpdates ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="agent-completion" className="text-base font-medium">
|
||||
Agent Completion
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when agents complete tasks
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="agent-completion"
|
||||
role="switch"
|
||||
aria-checked={notifications.agentCompletion}
|
||||
onClick={() => handleNotificationToggle('agentCompletion')}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
notifications.agentCompletion ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
notifications.agentCompletion ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="errors" className="text-base font-medium">
|
||||
Errors
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified about errors and failures
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="errors"
|
||||
role="switch"
|
||||
aria-checked={notifications.errors}
|
||||
onClick={() => handleNotificationToggle('errors')}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
notifications.errors ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
notifications.errors ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconServer className="h-5 w-5" />}
|
||||
title="Backend Connection"
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { IconBell, IconRefresh } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@kandev/ui/hover-card';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { SettingsPageTemplate } from '@/components/settings/settings-page-template';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
createNotificationProvider,
|
||||
deleteNotificationProvider,
|
||||
listNotificationProviders,
|
||||
updateNotificationProvider,
|
||||
} from '@/lib/http';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { DEFAULT_NOTIFICATION_EVENTS, EVENT_LABELS } from '@/lib/notifications/events';
|
||||
import type { NotificationProvider } from '@/lib/types/http';
|
||||
|
||||
type ProviderUpdatePayload = {
|
||||
enabled?: boolean;
|
||||
events?: string[];
|
||||
config?: NotificationProvider['config'];
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function formatAppriseUrls(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string').join('\n');
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseAppriseUrls(value: string): string[] {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function extractUrls(config: NotificationProvider['config']): string[] {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
const urls = config.urls;
|
||||
if (Array.isArray(urls)) {
|
||||
return urls.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
if (typeof urls === 'string') {
|
||||
return parseAppriseUrls(urls);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeEvents(events?: string[]): string {
|
||||
if (!Array.isArray(events)) {
|
||||
return '';
|
||||
}
|
||||
return [...events].sort().join('|');
|
||||
}
|
||||
|
||||
type NotificationsSettingsProps = {
|
||||
initialProviders?: NotificationProvider[];
|
||||
initialEvents?: string[];
|
||||
initialAppriseAvailable?: boolean;
|
||||
};
|
||||
|
||||
type AppriseFormMode = 'create' | 'edit';
|
||||
|
||||
function buildAppriseEdits(providers: NotificationProvider[]) {
|
||||
const urls: Record<string, string> = {};
|
||||
const names: Record<string, string> = {};
|
||||
for (const provider of providers) {
|
||||
if (provider.type !== 'apprise') {
|
||||
continue;
|
||||
}
|
||||
urls[provider.id] = formatAppriseUrls(provider.config?.urls);
|
||||
names[provider.id] = provider.name;
|
||||
}
|
||||
return { urls, names };
|
||||
}
|
||||
|
||||
export function NotificationsSettings({
|
||||
initialProviders,
|
||||
initialEvents,
|
||||
initialAppriseAvailable,
|
||||
}: NotificationsSettingsProps) {
|
||||
const [providers, setProviders] = useState<NotificationProvider[]>(() => initialProviders ?? []);
|
||||
const [baselineProviders, setBaselineProviders] = useState<NotificationProvider[]>(
|
||||
() => initialProviders ?? []
|
||||
);
|
||||
const [notificationEvents, setNotificationEvents] = useState<string[]>(
|
||||
() => initialEvents ?? []
|
||||
);
|
||||
const [appriseAvailable, setAppriseAvailable] = useState(
|
||||
() => initialAppriseAvailable ?? true
|
||||
);
|
||||
const [isProvidersLoading, setIsProvidersLoading] = useState(false);
|
||||
const [appriseName, setAppriseName] = useState('');
|
||||
const [appriseUrls, setAppriseUrls] = useState('');
|
||||
const [appriseEdits, setAppriseEdits] = useState<Record<string, string>>(() => {
|
||||
return buildAppriseEdits(initialProviders ?? []).urls;
|
||||
});
|
||||
const [appriseNameEdits, setAppriseNameEdits] = useState<Record<string, string>>(() => {
|
||||
return buildAppriseEdits(initialProviders ?? []).names;
|
||||
});
|
||||
const [showAppriseForm, setShowAppriseForm] = useState(false);
|
||||
const [appriseFormMode, setAppriseFormMode] = useState<AppriseFormMode>('create');
|
||||
const [activeAppriseId, setActiveAppriseId] = useState<string | null>(null);
|
||||
const [pendingDeletes, setPendingDeletes] = useState<Set<string>>(new Set());
|
||||
const [notificationPermission, setNotificationPermission] = useState<
|
||||
NotificationPermission | 'unsupported'
|
||||
>('default');
|
||||
|
||||
const saveRequest = useRequest(async () => {
|
||||
const updates: Array<Promise<NotificationProvider>> = [];
|
||||
for (const provider of providers) {
|
||||
const baseline = baselineProviders.find((item) => item.id === provider.id);
|
||||
if (!baseline) {
|
||||
continue;
|
||||
}
|
||||
const payload = buildProviderUpdate(provider, baseline);
|
||||
if (!payload) {
|
||||
continue;
|
||||
}
|
||||
updates.push(updateNotificationProvider(provider.id, payload));
|
||||
}
|
||||
const deletions = Array.from(pendingDeletes);
|
||||
for (const providerId of deletions) {
|
||||
await deleteNotificationProvider(providerId);
|
||||
}
|
||||
const updated = await Promise.all(updates);
|
||||
if (updated.length === 0) {
|
||||
setBaselineProviders(providers);
|
||||
setPendingDeletes(new Set());
|
||||
return [] as NotificationProvider[];
|
||||
}
|
||||
const updatedById = new Map(updated.map((provider) => [provider.id, provider]));
|
||||
const nextProviders = providers.map((provider) => updatedById.get(provider.id) ?? provider);
|
||||
setProviders(nextProviders);
|
||||
setBaselineProviders(nextProviders);
|
||||
setAppriseEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const provider of nextProviders) {
|
||||
if (provider.type === 'apprise') {
|
||||
next[provider.id] = formatAppriseUrls(provider.config?.urls);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAppriseNameEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const provider of nextProviders) {
|
||||
if (provider.type === 'apprise') {
|
||||
next[provider.id] = provider.name;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setPendingDeletes(new Set());
|
||||
return updated;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
setNotificationPermission('unsupported');
|
||||
} else {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialProviders) {
|
||||
return;
|
||||
}
|
||||
const fetchProviders = async () => {
|
||||
setIsProvidersLoading(true);
|
||||
try {
|
||||
const response = await listNotificationProviders();
|
||||
const loaded = response.providers ?? [];
|
||||
setProviders(loaded);
|
||||
setBaselineProviders(loaded);
|
||||
setNotificationEvents(response.events ?? []);
|
||||
setAppriseAvailable(response.apprise_available ?? true);
|
||||
const edits = buildAppriseEdits(loaded);
|
||||
setAppriseEdits(edits.urls);
|
||||
setAppriseNameEdits(edits.names);
|
||||
} catch (error) {
|
||||
console.error('[NotificationsSettings] Failed to load notification providers', error);
|
||||
} finally {
|
||||
setIsProvidersLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProviders();
|
||||
}, [initialProviders]);
|
||||
|
||||
const appriseProviders = providers.filter((provider) => provider.type === 'apprise');
|
||||
|
||||
const tableProviders = useMemo(() => {
|
||||
return [...providers].sort((left, right) => {
|
||||
if (left.type === right.type) {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
if (left.type === 'local') {
|
||||
return -1;
|
||||
}
|
||||
if (right.type === 'local') {
|
||||
return 1;
|
||||
}
|
||||
return left.type.localeCompare(right.type);
|
||||
});
|
||||
}, [providers]);
|
||||
const tableEvents = useMemo(() => {
|
||||
if (notificationEvents.length > 0) {
|
||||
return notificationEvents;
|
||||
}
|
||||
const eventSet = new Set<string>();
|
||||
for (const provider of providers) {
|
||||
for (const event of provider.events ?? []) {
|
||||
eventSet.add(event);
|
||||
}
|
||||
}
|
||||
return eventSet.size ? Array.from(eventSet) : DEFAULT_NOTIFICATION_EVENTS;
|
||||
}, [notificationEvents, providers]);
|
||||
|
||||
const isDirty =
|
||||
pendingDeletes.size > 0 ||
|
||||
providers.some((provider) => {
|
||||
const baseline = baselineProviders.find((item) => item.id === provider.id);
|
||||
if (!baseline) {
|
||||
return false;
|
||||
}
|
||||
if (buildProviderUpdate(provider, baseline)) {
|
||||
return true;
|
||||
}
|
||||
if (provider.type === 'apprise') {
|
||||
const currentValue = appriseEdits[provider.id] ?? formatAppriseUrls(provider.config?.urls);
|
||||
const baselineValue = formatAppriseUrls(baseline.config?.urls);
|
||||
const nameValue = appriseNameEdits[provider.id] ?? provider.name;
|
||||
return currentValue !== baselineValue || nameValue !== baseline.name;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
setNotificationPermission('unsupported');
|
||||
return;
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
setNotificationPermission(result);
|
||||
};
|
||||
|
||||
const handleRefreshPermission = () => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
setNotificationPermission('unsupported');
|
||||
return;
|
||||
}
|
||||
setNotificationPermission(Notification.permission);
|
||||
};
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return;
|
||||
}
|
||||
let permission = Notification.permission;
|
||||
if (permission === 'default') {
|
||||
permission = await Notification.requestPermission();
|
||||
setNotificationPermission(permission);
|
||||
}
|
||||
if (permission !== 'granted') {
|
||||
return;
|
||||
}
|
||||
new Notification('Test notification', {
|
||||
body: 'If you can read this, browser notifications are working.',
|
||||
});
|
||||
};
|
||||
|
||||
const updateProviderState = (providerId: string, updater: (provider: NotificationProvider) => NotificationProvider) => {
|
||||
setProviders((prev) => prev.map((provider) => (provider.id === providerId ? updater(provider) : provider)));
|
||||
};
|
||||
|
||||
const handleToggleEvent = (provider: NotificationProvider, eventType: string) => {
|
||||
const currentEvents = provider.events ?? [];
|
||||
const hasEvent = currentEvents.includes(eventType);
|
||||
const nextEvents = hasEvent
|
||||
? currentEvents.filter((event) => event !== eventType)
|
||||
: [...currentEvents, eventType];
|
||||
updateProviderState(provider.id, (current) => ({ ...current, events: nextEvents }));
|
||||
};
|
||||
|
||||
const handleCreateAppriseProvider = async () => {
|
||||
const urls = parseAppriseUrls(appriseUrls);
|
||||
if (urls.length === 0) {
|
||||
return;
|
||||
}
|
||||
const defaultEvents =
|
||||
notificationEvents.length > 0 ? notificationEvents : DEFAULT_NOTIFICATION_EVENTS;
|
||||
try {
|
||||
const created = await createNotificationProvider({
|
||||
name: appriseName.trim() || 'Apprise',
|
||||
type: 'apprise',
|
||||
config: { urls },
|
||||
enabled: true,
|
||||
events: defaultEvents,
|
||||
});
|
||||
setProviders((prev) => [...prev, created]);
|
||||
setBaselineProviders((prev) => [...prev, created]);
|
||||
setAppriseEdits((prev) => ({ ...prev, [created.id]: urls.join('\n') }));
|
||||
setAppriseNameEdits((prev) => ({ ...prev, [created.id]: created.name }));
|
||||
setAppriseUrls('');
|
||||
setShowAppriseForm(false);
|
||||
} catch (error) {
|
||||
console.error('[NotificationsSettings] Failed to create apprise provider', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProvider = (providerId: string) => {
|
||||
setPendingDeletes((prev) => new Set(prev).add(providerId));
|
||||
setProviders((prev) => prev.filter((provider) => provider.id !== providerId));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveRequest.run();
|
||||
} catch (error) {
|
||||
console.error('[NotificationsSettings] Failed to save notifications', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppriseEdit = (providerId: string, value: string) => {
|
||||
setAppriseEdits((prev) => ({ ...prev, [providerId]: value }));
|
||||
const urls = parseAppriseUrls(value);
|
||||
updateProviderState(providerId, (provider) => ({
|
||||
...provider,
|
||||
config: { ...provider.config, urls },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAppriseNameEdit = (providerId: string, value: string) => {
|
||||
setAppriseNameEdits((prev) => ({ ...prev, [providerId]: value }));
|
||||
updateProviderState(providerId, (provider) => ({
|
||||
...provider,
|
||||
name: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTextareaInput = (event: FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
};
|
||||
|
||||
const openAppriseForm = (mode: AppriseFormMode, provider?: NotificationProvider) => {
|
||||
setAppriseFormMode(mode);
|
||||
setActiveAppriseId(provider?.id ?? null);
|
||||
if (provider) {
|
||||
setAppriseName(appriseNameEdits[provider.id] ?? provider.name);
|
||||
setAppriseUrls(appriseEdits[provider.id] ?? formatAppriseUrls(provider.config?.urls));
|
||||
} else {
|
||||
setAppriseName('');
|
||||
setAppriseUrls('');
|
||||
}
|
||||
setShowAppriseForm(true);
|
||||
};
|
||||
|
||||
const closeAppriseForm = () => {
|
||||
setShowAppriseForm(false);
|
||||
setActiveAppriseId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsPageTemplate
|
||||
title="Notifications"
|
||||
description="Configure providers and choose which events should alert you."
|
||||
isDirty={isDirty}
|
||||
saveStatus={saveRequest.status}
|
||||
onSave={handleSave}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-base font-medium">Desktop Notifications</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Notify this device when an agent needs your input.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
title='Enable desktop notifications'
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleRequestPermission}
|
||||
disabled={
|
||||
notificationPermission === 'granted' ||
|
||||
notificationPermission === 'unsupported'
|
||||
}
|
||||
className={
|
||||
notificationPermission === 'granted'
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-500'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{notificationPermission === 'granted' ? 'Enabled' : 'Enable'}
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={handleRefreshPermission}>
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh permission status</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
title="Send test notification"
|
||||
variant="outline"
|
||||
className='cursor-pointer'
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
void handleTestNotification();
|
||||
}}
|
||||
>
|
||||
<IconBell className="h-4 w-4" />
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" className="text-sm">
|
||||
If you do not see notifications, check your OS settings and allow this browser.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notificationPermission === 'denied' && (
|
||||
<p className="text-sm text-amber-600">
|
||||
Notifications are blocked in your browser. Enable them in site settings, then click
|
||||
Refresh.
|
||||
</p>
|
||||
)}
|
||||
{notificationPermission === 'unsupported' && (
|
||||
<p className="text-sm text-amber-600">
|
||||
This browser does not support desktop notifications.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className='my-4' />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-base font-medium">External Providers</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure external providers for remote notifications.
|
||||
</p>
|
||||
</div>
|
||||
{!appriseAvailable && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apprise is not installed yet. You can add it later to enable remote notifications.{' '}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View installation instructions
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{appriseProviders.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No Apprise providers configured yet.</p>
|
||||
)}
|
||||
{appriseProviders.map((provider) => {
|
||||
const isEditing = appriseFormMode === 'edit' && activeAppriseId === provider.id;
|
||||
return (
|
||||
<div key={provider.id} className="rounded-lg border border-muted p-4 space-y-3">
|
||||
{isEditing ? (
|
||||
<AppriseProviderForm
|
||||
mode="edit"
|
||||
name={appriseName}
|
||||
urls={appriseUrls}
|
||||
onNameChange={(value) => {
|
||||
setAppriseName(value);
|
||||
handleAppriseNameEdit(provider.id, value);
|
||||
}}
|
||||
onUrlsChange={(value) => {
|
||||
setAppriseUrls(value);
|
||||
handleAppriseEdit(provider.id, value);
|
||||
}}
|
||||
onSubmit={async () => {
|
||||
closeAppriseForm();
|
||||
}}
|
||||
onCancel={closeAppriseForm}
|
||||
onInput={handleTextareaInput}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="font-medium">{provider.name}</div>
|
||||
<div className="text-xs text-muted-foreground">Apprise</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => openAppriseForm('edit', provider)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleDeleteProvider(provider.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{appriseAvailable && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => openAppriseForm('create')}
|
||||
>
|
||||
Add Apprise Provider
|
||||
</Button>
|
||||
{showAppriseForm && appriseFormMode === 'create' && (
|
||||
<AppriseProviderForm
|
||||
mode="create"
|
||||
name={appriseName}
|
||||
urls={appriseUrls}
|
||||
onNameChange={(value) => setAppriseName(value)}
|
||||
onUrlsChange={(value) => setAppriseUrls(value)}
|
||||
onSubmit={async () => {
|
||||
await handleCreateAppriseProvider();
|
||||
closeAppriseForm();
|
||||
}}
|
||||
onCancel={closeAppriseForm}
|
||||
onInput={handleTextareaInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className='my-4' />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-base font-medium">Notification Events</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select which providers should receive each notification type.
|
||||
</p>
|
||||
</div>
|
||||
{isProvidersLoading && (
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
)}
|
||||
{!isProvidersLoading && tableProviders.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No providers configured yet.</p>
|
||||
)}
|
||||
{tableProviders.length > 0 && (
|
||||
<div className="overflow-auto rounded-lg border border-muted">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-muted/40">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">Notification type</th>
|
||||
{tableProviders.map((provider) => (
|
||||
<th key={provider.id} className="px-4 py-3 text-center font-medium">
|
||||
{provider.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableEvents.map((eventType) => {
|
||||
const meta = EVENT_LABELS[eventType] ?? {
|
||||
title: eventType,
|
||||
description: 'Notify when this event occurs.',
|
||||
};
|
||||
return (
|
||||
<tr key={eventType} className="border-t border-muted">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{meta.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{meta.description}</div>
|
||||
</td>
|
||||
{tableProviders.map((provider) => (
|
||||
<td key={provider.id} className="px-4 py-3 text-center">
|
||||
<div className="flex justify-center">
|
||||
<Checkbox
|
||||
checked={(provider.events ?? []).includes(eventType)}
|
||||
onCheckedChange={() => handleToggleEvent(provider, eventType)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
type AppriseProviderFormProps = {
|
||||
mode: AppriseFormMode;
|
||||
name: string;
|
||||
urls: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onUrlsChange: (value: string) => void;
|
||||
onSubmit: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
onInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
};
|
||||
|
||||
function AppriseProviderForm({
|
||||
mode,
|
||||
name,
|
||||
urls,
|
||||
onNameChange,
|
||||
onUrlsChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onInput,
|
||||
}: AppriseProviderFormProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-muted p-4 space-y-3">
|
||||
<div className="text-base font-medium">Apprise Provider</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="Provider name"
|
||||
/>
|
||||
<Textarea
|
||||
value={urls}
|
||||
onChange={(event) => onUrlsChange(event.target.value)}
|
||||
onInput={onInput}
|
||||
placeholder="Service URL(s)"
|
||||
rows={1}
|
||||
className="min-h-0 h-auto"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button className="cursor-pointer" onClick={onSubmit}>
|
||||
{mode === 'create' ? 'Add provider' : 'Done'}
|
||||
</Button>
|
||||
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildProviderUpdate(
|
||||
provider: NotificationProvider,
|
||||
baseline: NotificationProvider
|
||||
): ProviderUpdatePayload | null {
|
||||
const updates: ProviderUpdatePayload = {};
|
||||
if (provider.enabled !== baseline.enabled) {
|
||||
updates.enabled = provider.enabled;
|
||||
}
|
||||
if (normalizeEvents(provider.events) !== normalizeEvents(baseline.events)) {
|
||||
updates.events = provider.events;
|
||||
}
|
||||
if (provider.name !== baseline.name) {
|
||||
updates.name = provider.name;
|
||||
}
|
||||
if (provider.type === 'apprise') {
|
||||
const urls = extractUrls(provider.config);
|
||||
const baselineUrls = extractUrls(baseline.config);
|
||||
if (urls.join('|') !== baselineUrls.join('|')) {
|
||||
updates.config = { ...provider.config, urls };
|
||||
}
|
||||
}
|
||||
return Object.keys(updates).length ? updates : null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconFolder,
|
||||
IconServer,
|
||||
IconRobot,
|
||||
IconBell,
|
||||
IconChevronRight,
|
||||
IconCpu,
|
||||
IconPlug,
|
||||
@@ -62,14 +63,38 @@ export function SettingsAppSidebar() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{/* General */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === '/settings/general'}>
|
||||
<Link href="/settings/general">
|
||||
<IconSettings className="h-4 w-4" />
|
||||
<span>General</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="General">
|
||||
<Link href="/settings/general">
|
||||
<IconSettings className="h-4 w-4" />
|
||||
<span>General</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle</span>
|
||||
</SidebarMenuAction>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
size="sm"
|
||||
isActive={pathname === '/settings/general/notifications'}
|
||||
>
|
||||
<Link href="/settings/general/notifications">
|
||||
<IconBell className="h-4 w-4" />
|
||||
<span>Notifications</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
|
||||
{/* Workspaces */}
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
|
||||
|
||||
type SettingsPageTemplateProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
isDirty: boolean;
|
||||
saveStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
onSave: () => void;
|
||||
children: React.ReactNode;
|
||||
deleteSection?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsPageTemplate({
|
||||
title,
|
||||
description,
|
||||
isDirty,
|
||||
saveStatus,
|
||||
onSave,
|
||||
children,
|
||||
deleteSection,
|
||||
}: SettingsPageTemplateProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isDirty && <UnsavedChangesBadge />}
|
||||
<UnsavedSaveButton
|
||||
isDirty={isDirty}
|
||||
isLoading={saveStatus === 'loading'}
|
||||
status={saveStatus}
|
||||
onClick={onSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card className={isDirty ? 'border border-yellow-500/60' : 'border'}>
|
||||
<CardContent className="">{children}</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteSection}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,17 +21,19 @@ export function UnsavedSaveButton({ isDirty, isLoading, status, onClick }: Unsav
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size="lg"
|
||||
variant={isDirty ? 'secondary' : 'outline'}
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={isDirty ? warningButtonClass : undefined}
|
||||
className={isDirty ? warningButtonClass : 'cursor-pointer'}
|
||||
>
|
||||
{isDirty && <IconAlertCircle className="h-4 w-4 mr-2" />}
|
||||
Save
|
||||
<span className="ml-2">
|
||||
<RequestIndicator status={status} />
|
||||
</span>
|
||||
{status !== 'idle' && (
|
||||
<span className="ml-2">
|
||||
<RequestIndicator status={status} />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
ListWorkspacesResponse,
|
||||
ListMessagesResponse,
|
||||
RepositoryBranchesResponse,
|
||||
NotificationProvidersResponse,
|
||||
NotificationProvider,
|
||||
Task,
|
||||
TaskSessionsResponse,
|
||||
UserSettingsResponse,
|
||||
@@ -163,6 +165,50 @@ export async function updateUserSettings(
|
||||
});
|
||||
}
|
||||
|
||||
export async function listNotificationProviders(options?: ApiRequestOptions) {
|
||||
return fetchJson<NotificationProvidersResponse>('/api/v1/notification-providers', options);
|
||||
}
|
||||
|
||||
export async function createNotificationProvider(
|
||||
payload: {
|
||||
name: string;
|
||||
type: NotificationProvider['type'];
|
||||
config?: NotificationProvider['config'];
|
||||
enabled?: boolean;
|
||||
events?: string[];
|
||||
},
|
||||
options?: ApiRequestOptions
|
||||
) {
|
||||
return fetchJson<NotificationProvider>('/api/v1/notification-providers', {
|
||||
...options,
|
||||
init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateNotificationProvider(
|
||||
providerId: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
type: NotificationProvider['type'];
|
||||
config: NotificationProvider['config'];
|
||||
enabled: boolean;
|
||||
events: string[];
|
||||
}>,
|
||||
options?: ApiRequestOptions
|
||||
) {
|
||||
return fetchJson<NotificationProvider>(`/api/v1/notification-providers/${providerId}`, {
|
||||
...options,
|
||||
init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteNotificationProvider(providerId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<void>(`/api/v1/notification-providers/${providerId}`, {
|
||||
...options,
|
||||
init: { method: 'DELETE', ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTask(taskId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<Task>(`/api/v1/tasks/${taskId}`, options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT =
|
||||
'task_session.waiting_for_input';
|
||||
|
||||
export const DEFAULT_NOTIFICATION_EVENTS = [NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT];
|
||||
|
||||
export const EVENT_LABELS: Record<string, { title: string; description: string }> = {
|
||||
[NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT]: {
|
||||
title: 'A session has finished',
|
||||
description: 'Notify when an agent is waiting for your reply.',
|
||||
},
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export type BackendMessageType =
|
||||
| 'message.added'
|
||||
| 'message.updated'
|
||||
| 'task_session.state_changed'
|
||||
| 'task_session.waiting_for_input'
|
||||
| 'executor.created'
|
||||
| 'executor.updated'
|
||||
| 'executor.deleted'
|
||||
@@ -148,6 +149,13 @@ export type TaskSessionStateChangedPayload = {
|
||||
new_state: string;
|
||||
};
|
||||
|
||||
export type TaskSessionWaitingForInputPayload = {
|
||||
task_id: string;
|
||||
task_session_id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type FileInfo = {
|
||||
path: string;
|
||||
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed';
|
||||
@@ -255,6 +263,7 @@ export type BackendMessageMap = {
|
||||
'message.added': BackendMessage<'message.added', MessageAddedPayload>;
|
||||
'message.updated': BackendMessage<'message.updated', MessageAddedPayload>;
|
||||
'task_session.state_changed': BackendMessage<'task_session.state_changed', TaskSessionStateChangedPayload>;
|
||||
'task_session.waiting_for_input': BackendMessage<'task_session.waiting_for_input', TaskSessionWaitingForInputPayload>;
|
||||
'executor.created': BackendMessage<'executor.created', ExecutorPayload>;
|
||||
'executor.updated': BackendMessage<'executor.updated', ExecutorPayload>;
|
||||
'executor.deleted': BackendMessage<'executor.deleted', ExecutorPayload>;
|
||||
|
||||
@@ -140,6 +140,25 @@ export type TaskSessionsResponse = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type NotificationProviderType = 'local' | 'apprise';
|
||||
|
||||
export type NotificationProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: NotificationProviderType;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
events: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type NotificationProvidersResponse = {
|
||||
providers: NotificationProvider[];
|
||||
apprise_available: boolean;
|
||||
events: string[];
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { StoreApi } from 'zustand';
|
||||
import { NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT } from '@/lib/notifications/events';
|
||||
import type { AppState } from '@/lib/state/store';
|
||||
import type { WsHandlers } from '@/lib/ws/handlers/types';
|
||||
|
||||
export function registerNotificationsHandlers(_store: StoreApi<AppState>): WsHandlers {
|
||||
void _store;
|
||||
return {
|
||||
[NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT]: (message) => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (Notification.permission !== 'granted') {
|
||||
return;
|
||||
}
|
||||
const title = message.payload.title || 'Task needs your input';
|
||||
const body = message.payload.body || 'An agent is waiting for your input.';
|
||||
new Notification(title, { body });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { registerTaskSessionHandlers } from '@/lib/ws/handlers/agent-session';
|
||||
import { registerBoardsHandlers } from '@/lib/ws/handlers/boards';
|
||||
import { registerColumnsHandlers } from '@/lib/ws/handlers/columns';
|
||||
import { registerMessagesHandlers } from '@/lib/ws/handlers/messages';
|
||||
import { registerNotificationsHandlers } from '@/lib/ws/handlers/notifications';
|
||||
import { registerDiffsHandlers } from '@/lib/ws/handlers/diffs';
|
||||
import { registerEnvironmentsHandlers } from '@/lib/ws/handlers/environments';
|
||||
import { registerExecutorsHandlers } from '@/lib/ws/handlers/executors';
|
||||
@@ -31,6 +32,7 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
|
||||
...registerTerminalsHandlers(store),
|
||||
...registerDiffsHandlers(store),
|
||||
...registerMessagesHandlers(store),
|
||||
...registerNotificationsHandlers(store),
|
||||
...registerGitStatusHandlers(store),
|
||||
...registerSystemEventsHandlers(store),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user