Skip to content

Commit

Permalink
Merge pull request #9 from wesen/task/add-ui-server-update-ui
Browse files Browse the repository at this point in the history
Add update-ui tool with synchronous UI action handling
  • Loading branch information
wesen authored Feb 27, 2025
2 parents 5c8a162 + 063a170 commit 0e48a4a
Show file tree
Hide file tree
Showing 18 changed files with 1,982 additions and 816 deletions.
9 changes: 9 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ Added structured metadata to technical documentation for better maintainability:
- Structured questions the document answers for better discoverability
- Improved documentation organization and searchability

# UI Action Resolved Status and Auto-Reset

Enhanced the UI action handling to return the resolved status in the response and automatically reset the UI when an action resolves a waiting request:
- Modified the handleUIAction function to include the resolved status in the response
- Updated the sendUIAction JavaScript function to handle resolved actions
- Added automatic form reset after a successful form submission that resolves a request
- Replaced the entire UI with a simplified waiting message after a resolved action
- This improves the user experience by providing immediate feedback and resetting the UI state

# UI Action Handling Documentation

Added comprehensive documentation for the UI action handling system:
Expand Down
304 changes: 15 additions & 289 deletions cmd/ui-server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import (
"github.com/go-go-golems/clay/pkg/watcher"
"github.com/go-go-golems/go-go-mcp/pkg/events"
"github.com/go-go-golems/go-go-mcp/pkg/server/sse"
"github.com/go-go-golems/go-go-mcp/pkg/server/ui"
"github.com/rs/zerolog/log"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
)

type Server struct {
dir string
pages map[string]UIDefinition
pages map[string]ui.UIDefinition
routes map[string]http.HandlerFunc
watcher *watcher.Watcher
mux *http.ServeMux
Expand All @@ -35,10 +36,7 @@ type Server struct {
subscriber message.Subscriber
events events.EventManager
sseHandler *sse.SSEHandler
}

type UIDefinition struct {
Components []map[string]interface{} `yaml:"components"`
uiHandler *ui.UIHandler
}

func NewServer(dir string) (*Server, error) {
Expand All @@ -57,15 +55,19 @@ func NewServer(dir string) (*Server, error) {
// Create SSE handler
sseHandler := sse.NewSSEHandler(eventManager, &log.Logger)

// Create UI handler
uiHandler := ui.NewUIHandler(eventManager, sseHandler, &log.Logger)

s := &Server{
dir: dir,
pages: make(map[string]UIDefinition),
pages: make(map[string]ui.UIDefinition),
routes: make(map[string]http.HandlerFunc),
mux: http.NewServeMux(),
publisher: publisher,
subscriber: publisher,
events: eventManager,
sseHandler: sseHandler,
uiHandler: uiHandler,
}

// Register component renderers
Expand All @@ -88,11 +90,8 @@ func NewServer(dir string) (*Server, error) {
// Set up static file handler
s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))

// Set up UI update endpoint
s.mux.Handle("/api/ui-update", s.handleUIUpdate())

// Set up UI action endpoint
s.mux.Handle("/api/ui-action", s.handleUIAction())
// Register UI handlers
uiHandler.RegisterHandlers(s.mux)

// Set up UI update page
s.mux.HandleFunc("/ui", s.handleUIUpdatePage())
Expand Down Expand Up @@ -178,7 +177,7 @@ func (s *Server) loadPages() error {

// First, clear existing pages and routes
s.mu.Lock()
s.pages = make(map[string]UIDefinition)
s.pages = make(map[string]ui.UIDefinition)
s.routes = make(map[string]http.HandlerFunc)
s.mu.Unlock()

Expand All @@ -196,8 +195,8 @@ func (s *Server) loadPages() error {
if strings.HasSuffix(d.Name(), ".yaml") {
log.Debug().Str("path", path).Msg("Found YAML page")
if err := s.loadPage(path); err != nil {
log.Error().Err(err).Str("path", path).Msg("Failed to load page")
return err
log.Warn().Err(err).Str("path", path).Msg("Failed to load page")
return nil
}
}
return nil
Expand All @@ -212,7 +211,7 @@ func (s *Server) loadPage(path string) error {
return fmt.Errorf("failed to read file %s: %w", path, err)
}

var def UIDefinition
var def ui.UIDefinition
if err := yaml.Unmarshal(data, &def); err != nil {
log.Error().Err(err).Str("path", path).Msg("Failed to parse YAML")
return fmt.Errorf("failed to parse YAML in %s: %w", path, err)
Expand Down Expand Up @@ -337,7 +336,7 @@ func (s *Server) registerComponentRenderers() {
log.Debug().Str("pageID", pageID).Msg("Rendering page content template")

// Get the page definition
var def UIDefinition
var def ui.UIDefinition

// Try to extract definition from event data
if compData, ok := data.(map[string]interface{}); ok {
Expand Down Expand Up @@ -372,279 +371,6 @@ func (s *Server) registerComponentRenderers() {
})
}

// handleUIUpdate handles POST requests to /api/ui-update
func (s *Server) handleUIUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Only accept POST requests
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

// Parse JSON body
var jsonData map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&jsonData); err != nil {
// Return detailed JSON error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "json_parse_error",
"message": "Invalid JSON: " + err.Error(),
},
})
return
}

// Convert to YAML for storage
yamlData, err := yaml.Marshal(jsonData)
if err != nil {
// Return detailed JSON error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "yaml_conversion_error",
"message": "Failed to convert to YAML: " + err.Error(),
},
})
return
}

// Parse into UIDefinition
var def UIDefinition
if err := yaml.Unmarshal(yamlData, &def); err != nil {
// Return detailed JSON error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "ui_definition_error",
"message": "Invalid UI definition: " + err.Error(),
"yaml": string(yamlData),
},
})
return
}

// Validate the UI definition
validationErrors := s.validateUIDefinition(def)
if len(validationErrors) > 0 {
// Return detailed JSON error response with validation errors
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "ui_validation_error",
"message": "UI definition validation failed",
"details": validationErrors,
},
})
return
}

// Create and publish refresh-ui event
event := events.UIEvent{
Type: "refresh-ui",
PageID: "ui-update",
Component: def,
}

if err := s.events.Publish("ui-update", event); err != nil {
// Return detailed JSON error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "event_publish_error",
"message": "Failed to publish event: " + err.Error(),
},
})
return
}

// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(map[string]string{"status": "success"})
if err != nil {
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
}
}
}

// validateUIDefinition checks a UI definition for common errors
func (s *Server) validateUIDefinition(def UIDefinition) []map[string]interface{} {
var errors []map[string]interface{}

// Check if components exist
if len(def.Components) == 0 {
errors = append(errors, map[string]interface{}{
"path": "components",
"message": "No components defined",
})
return errors
}

// Validate each component
for i, comp := range def.Components {
for typ, props := range comp {
propsMap, ok := props.(map[string]interface{})
if !ok {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s", i, typ),
"message": "Component properties must be a map",
})
continue
}

// Check for required ID
if _, hasID := propsMap["id"]; !hasID && requiresID(typ) {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s", i, typ),
"message": "Component is missing required 'id' property",
})
}

// Validate nested components in forms
if typ == "form" {
if formComps, hasComps := propsMap["components"]; hasComps {
if formCompsList, ok := formComps.([]interface{}); ok {
for j, formComp := range formCompsList {
if formCompMap, ok := formComp.(map[string]interface{}); ok {
for formCompType, formCompProps := range formCompMap {
if _, ok := formCompProps.(map[string]interface{}); !ok {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.components[%d].%s", i, typ, j, formCompType),
"message": "Form component properties must be a map",
})
}
}
} else {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.components[%d]", i, typ, j),
"message": "Form component must be a map",
})
}
}
} else {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.components", i, typ),
"message": "Form components must be an array",
})
}
}
}

// Validate list items
if typ == "list" {
if items, hasItems := propsMap["items"]; hasItems {
if itemsList, ok := items.([]interface{}); ok {
for j, item := range itemsList {
if _, ok := item.(map[string]interface{}); !ok {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.items[%d]", i, typ, j),
"message": "List item must be a map",
})
}
}
} else {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.items", i, typ),
"message": "List items must be an array",
})
}
}
}
}
}

return errors
}

// requiresID returns true if the component type requires an ID
func requiresID(componentType string) bool {
switch componentType {
case "text", "title":
// These can optionally have IDs
return false
default:
// All other components require IDs
return true
}
}

// handleUIAction handles POST requests to /api/ui-action
func (s *Server) handleUIAction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Only accept POST requests
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

// Parse JSON body
var action struct {
ComponentID string `json:"componentId"`
Action string `json:"action"`
Data map[string]interface{} `json:"data"`
}

if err := json.NewDecoder(r.Body).Decode(&action); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}

// Determine if this is an important event to log at INFO level
isImportantEvent := false
switch action.Action {
case "clicked", "changed", "submitted":
isImportantEvent = true
}

// Log the action at appropriate level
logger := log.Debug()
if isImportantEvent {
logger = log.Info()
}

// Create log entry with component and action info
logger = logger.
Str("componentId", action.ComponentID).
Str("action", action.Action)

// Add data to log if it exists and is relevant
if len(action.Data) > 0 {
// For form submissions, log the form data in detail
if action.Action == "submitted" && action.Data["formData"] != nil {
logger = logger.Interface("formData", action.Data["formData"])
} else if action.Action == "changed" {
// For changed events, log the new value
logger = logger.Interface("data", action.Data)
} else {
// For other events, just log that data exists
logger = logger.Bool("hasData", true)
}
}

// Output the log message
logger.Msg("UI action received")

// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(map[string]string{"status": "success"})
if err != nil {
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
}
}
}

// handleUIUpdatePage renders the UI update page
func (s *Server) handleUIUpdatePage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading

0 comments on commit 0e48a4a

Please sign in to comment.