package main import ( "context" "embed" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/orchard9/peach/pkg/persona" "github.com/orchard9/peach/pkg/personagen" "github.com/orchard9/peach/pkg/personagen/storage" ) //go:embed index.html var staticFiles embed.FS const ( agentsDir = "agents" mediaDir = "media" ) var ( apiKey = os.Getenv("LAOZHANG_API_KEY") upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } ) // SafeConn wraps a WebSocket connection with a mutex for thread-safe writes type SafeConn struct { conn *websocket.Conn mu sync.Mutex } func (sc *SafeConn) WriteJSON(v interface{}) error { sc.mu.Lock() defer sc.mu.Unlock() return sc.conn.WriteJSON(v) } // Hub manages all WebSocket connections for broadcasting type Hub struct { mu sync.RWMutex clients map[*SafeConn]bool } func NewHub() *Hub { return &Hub{ clients: make(map[*SafeConn]bool), } } func (h *Hub) Register(sc *SafeConn) { h.mu.Lock() h.clients[sc] = true h.mu.Unlock() } func (h *Hub) Unregister(sc *SafeConn) { h.mu.Lock() delete(h.clients, sc) h.mu.Unlock() } func (h *Hub) Broadcast(msg interface{}) { h.mu.RLock() defer h.mu.RUnlock() for sc := range h.clients { sc.WriteJSON(msg) } } var hub = NewHub() // Agent represents a created agent (simplified view for UI) type Agent struct { ID string `json:"id"` Name string `json:"name"` Handle string `json:"handle"` Gender string `json:"gender"` Description string `json:"description"` Tags []string `json:"tags"` AvatarURL string `json:"avatar_url,omitempty"` BannerURL string `json:"banner_url,omitempty"` Videos []string `json:"videos,omitempty"` Images []string `json:"images,omitempty"` CreatedAt time.Time `json:"created_at"` DirName string `json:"dir_name,omitempty"` } // MediaJob tracks background media generation type MediaJob struct { ID string `json:"id"` AgentID string `json:"agent_id"` AgentName string `json:"agent_name"` Type string `json:"type"` // "anchor", "avatar", "banner", "video", "gallery" Status string `json:"status"` // "pending", "generating", "complete", "error" Progress int `json:"progress"` Total int `json:"total"` Error string `json:"error,omitempty"` } var ( jobsMu sync.RWMutex jobs = make(map[string]*MediaJob) agentsMu sync.Mutex // protects agent modifications and saves ) // WSMessage represents a WebSocket message type WSMessage struct { Type string `json:"type"` Payload interface{} `json:"payload"` } // CreateRequest is the payload for creating an agent type CreateRequest struct { Description string `json:"description"` Gender string `json:"gender"` } // GenerateMediaRequest is for generating media on existing agent type GenerateMediaRequest struct { AgentID string `json:"agent_id"` MediaType string `json:"media_type"` } // DeleteImageRequest is for deleting an image from an agent type DeleteImageRequest struct { AgentID string `json:"agent_id"` ImageURL string `json:"image_url"` } // DeleteVideoRequest is for deleting a video from an agent type DeleteVideoRequest struct { AgentID string `json:"agent_id"` VideoURL string `json:"video_url"` } // BulkDeleteRequest is for deleting multiple images/videos at once type BulkDeleteRequest struct { AgentID string `json:"agent_id"` ImageURLs []string `json:"image_urls,omitempty"` VideoURLs []string `json:"video_urls,omitempty"` } // RegeneratePositionsRequest is for regenerating specific image positions type RegeneratePositionsRequest struct { AgentID string `json:"agent_id"` Positions []int `json:"positions"` } // ModifyPersonaRequest is for modifying persona spec attributes type ModifyPersonaRequest struct { AgentID string `json:"agent_id"` Modification string `json:"modification"` // "tone_eye_color", "align_hair_brows" Intensity string `json:"intensity,omitempty"` } // RegenerateBannerRequest is for regenerating banner with optional style type RegenerateBannerRequest struct { AgentID string `json:"agent_id"` Style string `json:"style,omitempty"` // "lifestyle", "portrait", "scenic", "artistic" } // BulkRegenerateVideosRequest is for regenerating all videos for multiple agents type BulkRegenerateVideosRequest struct { AgentIDs []string `json:"agent_ids"` } func main() { if apiKey == "" { log.Fatal("LAOZHANG_API_KEY environment variable is required") } os.MkdirAll(agentsDir, 0755) os.MkdirAll(mediaDir, 0755) http.HandleFunc("/", serveIndex) http.HandleFunc("/ws", handleWebSocket) http.HandleFunc("/agents", listAgents) http.HandleFunc("/agents/", getAgent) http.Handle("/media/", http.StripPrefix("/media/", http.FileServer(http.Dir(mediaDir)))) port := os.Getenv("PORT") if port == "" { port = "8080" } log.Printf("Starting agent creator on http://localhost:%s", port) log.Printf("Agents saved to ./%s/, media saved to ./%s/", agentsDir, mediaDir) log.Fatal(http.ListenAndServe(":"+port, nil)) } func serveIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } content, _ := staticFiles.ReadFile("index.html") w.Header().Set("Content-Type", "text/html") w.Write(content) } func handleWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("WebSocket upgrade failed: %v", err) return } defer conn.Close() sc := &SafeConn{conn: conn} hub.Register(sc) defer hub.Unregister(sc) log.Println("WebSocket client connected") // Send current jobs jobsMu.RLock() for _, job := range jobs { sc.WriteJSON(WSMessage{Type: "job_update", Payload: job}) } jobsMu.RUnlock() for { _, message, err := conn.ReadMessage() if err != nil { if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { log.Println("WebSocket client disconnected") } return } var msg WSMessage if err := json.Unmarshal(message, &msg); err != nil { continue } switch msg.Type { case "create": payloadBytes, _ := json.Marshal(msg.Payload) var req CreateRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid create request") continue } go handleCreate(sc, req) case "generate_media": payloadBytes, _ := json.Marshal(msg.Payload) var req GenerateMediaRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid generate_media request") continue } go handleGenerateMedia(sc, req) case "delete_image": payloadBytes, _ := json.Marshal(msg.Payload) var req DeleteImageRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid delete_image request") continue } handleDeleteImage(sc, req) case "delete_video": payloadBytes, _ := json.Marshal(msg.Payload) var req DeleteVideoRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid delete_video request") continue } handleDeleteVideo(sc, req) case "bulk_delete": payloadBytes, _ := json.Marshal(msg.Payload) var req BulkDeleteRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid bulk_delete request") continue } handleBulkDelete(sc, req) case "clear_jobs": clearCompletedJobs() case "regenerate_positions": payloadBytes, _ := json.Marshal(msg.Payload) var req RegeneratePositionsRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid regenerate_positions request") continue } go handleRegeneratePositions(sc, req) case "modify_persona": payloadBytes, _ := json.Marshal(msg.Payload) var req ModifyPersonaRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid modify_persona request") continue } handleModifyPersona(sc, req) case "regenerate_banner": payloadBytes, _ := json.Marshal(msg.Payload) var req RegenerateBannerRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid regenerate_banner request") continue } go handleRegenerateBanner(sc, req) case "bulk_regenerate_videos": payloadBytes, _ := json.Marshal(msg.Payload) var req BulkRegenerateVideosRequest if err := json.Unmarshal(payloadBytes, &req); err != nil { sendError(sc, "Invalid bulk_regenerate_videos request") continue } go handleBulkRegenerateVideos(sc, req) default: sendError(sc, "Unknown message type: "+msg.Type) } } } func handleCreate(sc *SafeConn, req CreateRequest) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() send := func(msg WSMessage) { sc.WriteJSON(msg) } // Create personagen service cfg := personagen.ServiceConfig{ LaozhangAPIKey: apiKey, } progress := &wsProgressReporter{send: send} service, err := personagen.NewService(ctx, cfg, progress) if err != nil { sendError(sc, "Failed to create service: "+err.Error()) return } // Step 1: Generate persona spec send(WSMessage{Type: "step", Payload: map[string]string{"step": "personality"}}) // Extract ethnicity from description if present ethnicity := personagen.InferEthnicityFromDescription(req.Description) seed := personagen.SeedParams{ Gender: req.Gender, CustomArchetype: req.Description, Ethnicity: ethnicity, } spec, err := service.Generate(ctx, seed) if err != nil { sendError(sc, "Failed to generate persona: "+err.Error()) return } send(WSMessage{Type: "step_done", Payload: map[string]string{"step": "personality"}}) send(WSMessage{Type: "step_done", Payload: map[string]string{"step": "appearance"}}) send(WSMessage{Type: "step_done", Payload: map[string]string{"step": "identity"}}) send(WSMessage{Type: "step_done", Payload: map[string]string{"step": "tags"}}) // Validate name length (max 30 chars for UI display) fullName := fmt.Sprintf("%s %s", spec.CoreIdentity.Name.First, spec.CoreIdentity.Name.Last) if len(fullName) > 30 { sc.WriteJSON(WSMessage{Type: "error", Payload: map[string]interface{}{ "code": "NAME_TOO_LONG", "message": fmt.Sprintf("Generated name too long (%d chars, max 30): %s", len(fullName), fullName), }}) return } // Check for duplicate agent if existing, found := checkDuplicateAgent(fullName); found { sc.WriteJSON(WSMessage{Type: "error", Payload: map[string]interface{}{ "code": "DUPLICATE_AGENT", "message": fmt.Sprintf("Agent '%s' already exists", fullName), "existing_id": existing.ID, }}) return } // Create storage and save persona optimizer := storage.NewOptimizer(85, 80) // WebP quality 85, AVIF quality 80 store := storage.NewFilesystemStorage(mediaDir, optimizer) // Generate persona ID from name safeName := strings.ToLower(strings.ReplaceAll(spec.CoreIdentity.Name.First, " ", "_")) shortID := uuid.New().String()[:4] personaID := fmt.Sprintf("%s_%s", safeName, shortID) // Initialize ImageMatrix before saving (so it persists to disk) if len(spec.ImageMatrix) == 0 { spec.ImageMatrix = persona.ExtendedImageMatrix(75) } saveResult, err := store.SavePersona(ctx, spec, storage.SaveOptions{ ID: personaID, Format: storage.FormatJSON, Force: true, }) if err != nil { sendError(sc, "Failed to save persona: "+err.Error()) return } // Create agent for UI agent := &Agent{ ID: personaID, Name: fmt.Sprintf("%s %s", spec.CoreIdentity.Name.First, spec.CoreIdentity.Name.Last), Handle: strings.ToLower(spec.CoreIdentity.Name.First), Gender: req.Gender, Description: req.Description, Tags: extractTags(spec), Images: []string{}, CreatedAt: time.Now(), DirName: personaID, } saveAgentJSON(agent) send(WSMessage{Type: "complete", Payload: map[string]interface{}{"agent": agent}}) hub.Broadcast(WSMessage{Type: "agent_created", Payload: agent}) log.Printf("Created agent: %s (%s)", agent.Name, agent.ID) log.Printf("Persona saved to: %s", saveResult.Dir) // Spawn media generation with its own long-running context mediaCtx, mediaCancel := context.WithTimeout(context.Background(), 30*time.Minute) go func() { defer mediaCancel() spawnMediaJobs(mediaCtx, service, store, spec, agent) }() } func spawnMediaJobs(ctx context.Context, service *personagen.Service, store storage.Storage, spec *persona.PersonaSpec, agent *Agent) { personaID := agent.ID // Job 1: Generate anchor image (position 1) - MUST be first anchorJob := createJob(agent, "anchor", 1) anchorJob.Status = "generating" updateJob(anchorJob) log.Printf("Generating anchor image for %s...", agent.Name) images, err := service.GenerateImages(ctx, spec, []int{1}, nil) if err != nil { anchorJob.Status = "error" anchorJob.Error = err.Error() updateJob(anchorJob) log.Printf("Anchor generation failed for %s: %v", agent.Name, err) return } anchorBytes := images[1] // Save anchor anchorPath, err := store.SaveAnchor(ctx, personaID, anchorBytes) if err != nil { log.Printf("Failed to save anchor: %v", err) } else { log.Printf("Saved anchor: %s", anchorPath) } // Save as position 1 image if len(spec.ImageMatrix) > 0 { paths, err := store.SaveImage(ctx, personaID, 1, anchorBytes, &spec.ImageMatrix[0]) if err == nil { newImagePath := "/media/" + personaID + "/images/" + filepath.Base(paths.WebP) updatedAgent, err := updateAgentField(agent.ID, func(a *Agent) { a.Images = append(a.Images, newImagePath) }) if err == nil { hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) } } } // Set anchor for all subsequent generation service.SetAnchor(anchorBytes) anchorJob.Status = "complete" anchorJob.Progress = 100 updateJob(anchorJob) log.Printf("Anchor generated for %s", agent.Name) // Now run remaining jobs in parallel var wg sync.WaitGroup // Job 2: Avatar (uses anchor) wg.Add(1) go func() { defer wg.Done() generateAvatarJob(ctx, service, store, spec, agent) }() // Job 3: Banner (uses anchor) wg.Add(1) go func() { defer wg.Done() generateBannerJob(ctx, service, store, spec, agent) }() // Job 4: Gallery images (uses anchor, runs in parallel batches) wg.Add(1) go func() { defer wg.Done() generateGalleryJob(ctx, service, store, spec, agent) }() // Job 5: Video (uses anchor) wg.Add(1) go func() { defer wg.Done() generateVideoJob(ctx, service, store, spec, agent) }() wg.Wait() log.Printf("All media generation complete for %s", agent.Name) } func generateAvatarJob(ctx context.Context, service *personagen.Service, store storage.Storage, spec *persona.PersonaSpec, agent *Agent) { job := createJob(agent, "avatar", 1) job.Status = "generating" updateJob(job) result, err := service.GenerateAvatar(ctx, spec) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) log.Printf("Avatar generation failed for %s: %v", agent.Name, err) return } path, err := store.SaveAvatar(ctx, agent.ID, result.Data, result.Prompt) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) return } avatarURL := fmt.Sprintf("/media/%s/%s?t=%d", agent.ID, filepath.Base(path), time.Now().Unix()) updatedAgent, err := updateAgentField(agent.ID, func(a *Agent) { a.AvatarURL = avatarURL }) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) return } job.Status = "complete" job.Progress = 100 updateJob(job) hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) log.Printf("Avatar generated for %s: %s", agent.Name, path) } func generateBannerJob(ctx context.Context, service *personagen.Service, store storage.Storage, spec *persona.PersonaSpec, agent *Agent) { job := createJob(agent, "banner", 1) job.Status = "generating" updateJob(job) result, err := service.GenerateBanner(ctx, spec) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) log.Printf("Banner generation failed for %s: %v", agent.Name, err) return } path, err := store.SaveBanner(ctx, agent.ID, result.Data, result.Prompt) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) return } bannerURL := fmt.Sprintf("/media/%s/%s?t=%d", agent.ID, filepath.Base(path), time.Now().Unix()) updatedAgent, err := updateAgentField(agent.ID, func(a *Agent) { a.BannerURL = bannerURL }) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) return } job.Status = "complete" job.Progress = 100 updateJob(job) hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) log.Printf("Banner generated for %s: %s", agent.Name, path) } func generateAnchorJob(ctx context.Context, service *personagen.Service, store storage.Storage, spec *persona.PersonaSpec, agent *Agent) { job := createJob(agent, "anchor", 1) job.Status = "generating" updateJob(job) log.Printf("Regenerating anchor image for %s...", agent.Name) images, err := service.GenerateImages(ctx, spec, []int{1}, nil) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) log.Printf("Anchor regeneration failed for %s: %v", agent.Name, err) return } anchorBytes := images[1] // Save anchor anchorPath, err := store.SaveAnchor(ctx, agent.ID, anchorBytes) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) log.Printf("Failed to save anchor: %v", err) return } log.Printf("Saved anchor: %s", anchorPath) // Save as position 1 image (replace existing if any) paths, err := store.SaveImage(ctx, agent.ID, 1, anchorBytes, nil) if err == nil { newImagePath := "/media/" + agent.ID + "/images/" + filepath.Base(paths.WebP) updatedAgent, err := updateAgentField(agent.ID, func(a *Agent) { // Replace position 1 if exists, otherwise prepend found := false for i, imgURL := range a.Images { base := filepath.Base(imgURL) if len(base) >= 2 { var pos int fmt.Sscanf(base[:2], "%d", &pos) if pos == 1 { a.Images[i] = newImagePath found = true break } } } if !found { a.Images = append([]string{newImagePath}, a.Images...) } }) if err == nil { hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) } } job.Status = "complete" job.Progress = 100 updateJob(job) log.Printf("Anchor regenerated for %s", agent.Name) } func generateGalleryJob(ctx context.Context, service *personagen.Service, store storage.Storage, spec *persona.PersonaSpec, agent *Agent) { // Reload agent to get latest state (in case other jobs updated it) agentsMu.Lock() currentAgent, err := loadAgent(agent.ID) agentsMu.Unlock() if err != nil { log.Printf("Failed to reload agent for gallery: %v", err) return } // Find highest existing position highestPos := 1 // position 1 is anchor for _, imgURL := range currentAgent.Images { base := filepath.Base(imgURL) if len(base) >= 2 { var pos int fmt.Sscanf(base[:2], "%d", &pos) if pos > highestPos { highestPos = pos } } } // Generate next 10 positions (starting after highest, max 75) startPos := highestPos + 1 if startPos > 75 { log.Printf("All 75 positions already generated for %s", agent.Name) return } numToGenerate := 10 if startPos+numToGenerate-1 > 75 { numToGenerate = 75 - startPos + 1 } positionsToGenerate := make([]int, numToGenerate) for i := 0; i < numToGenerate; i++ { positionsToGenerate[i] = startPos + i } log.Printf("Generating positions %d-%d for %s (have %d images)", startPos, startPos+numToGenerate-1, agent.Name, len(currentAgent.Images)) job := createJob(agent, "gallery", numToGenerate) job.Status = "generating" updateJob(job) for i, pos := range positionsToGenerate { select { case <-ctx.Done(): log.Printf("Gallery generation cancelled for %s", agent.Name) job.Status = "error" job.Error = "cancelled" updateJob(job) return default: } log.Printf("Generating gallery image %d/%d for %s (position %d)...", i+1, numToGenerate, agent.Name, pos) images, err := service.GenerateImages(ctx, spec, []int{pos}, nil) if err != nil { log.Printf("Gallery image %d failed for %s: %v", pos, agent.Name, err) continue } imgBytes := images[pos] paths, err := store.SaveImage(ctx, agent.ID, pos, imgBytes, nil) if err != nil { log.Printf("Failed to save gallery image %d: %v", pos, err) continue } newImagePath := "/media/" + agent.ID + "/images/" + filepath.Base(paths.WebP) updatedAgent, err := updateAgentField(agent.ID, func(a *Agent) { a.Images = append(a.Images, newImagePath) }) if err == nil { hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) } job.Progress = int(float64(i+1) / float64(numToGenerate) * 100) updateJob(job) log.Printf("Gallery image %d generated for %s", pos, agent.Name) } job.Status = "complete" job.Progress = 100 updateJob(job) } func generateVideoJob(ctx context.Context, service *personagen.Service, store storage.Storage, spec *persona.PersonaSpec, agent *Agent) { // Reload agent to get latest state (in case other jobs updated it) agentsMu.Lock() currentAgent, err := loadAgent(agent.ID) agentsMu.Unlock() if err != nil { log.Printf("Failed to reload agent for video: %v", err) return } // Video positions 1-4, each with a motion type motionTypes := []struct { position int motionType string }{ {1, storage.MotionSmileReveal}, {2, storage.MotionPersonalityMoment}, {3, storage.MotionLifestyle}, {4, storage.MotionInvitation}, } // Find which videos already exist existingVideos := make(map[string]bool) for _, url := range currentAgent.Videos { existingVideos[filepath.Base(strings.Split(url, "?")[0])] = true } // Find next video to generate var toGenerate *struct { position int motionType string } for _, mt := range motionTypes { filename := fmt.Sprintf("%02d_%s.mp4", mt.position, mt.motionType) if !existingVideos[filename] { toGenerate = &struct { position int motionType string }{mt.position, mt.motionType} break } } if toGenerate == nil { log.Printf("All 4 video positions already generated for %s", agent.Name) return } job := createJob(agent, "video", 1) job.Status = "generating" updateJob(job) log.Printf("Generating video position %d (%s) for %s...", toGenerate.position, toGenerate.motionType, agent.Name) videos, err := service.GenerateVideos(ctx, spec, []int{toGenerate.position}) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) log.Printf("Video generation failed for %s: %v", agent.Name, err) return } videoBytes := videos[toGenerate.position] path, err := store.SaveVideo(ctx, agent.ID, toGenerate.position, toGenerate.motionType, videoBytes) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) return } videoURL := fmt.Sprintf("/media/%s/videos/%s?t=%d", agent.ID, filepath.Base(path), time.Now().Unix()) updatedAgent, err := updateAgentField(agent.ID, func(a *Agent) { a.Videos = append(a.Videos, videoURL) }) if err != nil { job.Status = "error" job.Error = err.Error() updateJob(job) return } job.Status = "complete" job.Progress = 100 updateJob(job) hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) log.Printf("Video generated for %s: %s", agent.Name, path) } func handleGenerateMedia(sc *SafeConn, req GenerateMediaRequest) { // Use a single long-running context for the entire operation // Don't defer cancel() here - the goroutine will cancel when done ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) // Load agent agent, err := loadAgent(req.AgentID) if err != nil { cancel() sendError(sc, "Agent not found: "+req.AgentID) return } // Create service with the long-running context cfg := personagen.ServiceConfig{ LaozhangAPIKey: apiKey, } service, err := personagen.NewService(ctx, cfg, &personagen.NoopProgressReporter{}) if err != nil { cancel() sendError(sc, "Failed to create service: "+err.Error()) return } // Load persona spec and anchor optimizer := storage.NewOptimizer(85, 80) store := storage.NewFilesystemStorage(mediaDir, optimizer) spec, err := store.LoadPersona(ctx, agent.ID) if err != nil { cancel() sendError(sc, "Failed to load persona: "+err.Error()) return } // Ensure ImageMatrix has 75 positions for extended generation if len(spec.ImageMatrix) < 75 { spec.ImageMatrix = persona.ExtendedImageMatrix(75) } // Handle anchor regeneration separately (doesn't need existing anchor) if req.MediaType == "anchor" { go func() { defer cancel() generateAnchorJob(ctx, service, store, spec, agent) }() return } // All other media types require an existing anchor anchorBytes, err := store.LoadAnchor(ctx, agent.ID) if err != nil { cancel() // Send specific error so frontend can show "Regenerate Anchor" button sc.WriteJSON(WSMessage{Type: "error", Payload: map[string]string{ "message": "Anchor not found", "code": "ANCHOR_MISSING", }}) return } service.SetAnchor(anchorBytes) switch req.MediaType { case "avatar": go func() { defer cancel() generateAvatarJob(ctx, service, store, spec, agent) }() case "banner": go func() { defer cancel() generateBannerJob(ctx, service, store, spec, agent) }() case "images": go func() { defer cancel() generateGalleryJob(ctx, service, store, spec, agent) }() case "video": go func() { defer cancel() generateVideoJob(ctx, service, store, spec, agent) }() default: cancel() sendError(sc, "Unknown media type: "+req.MediaType) } } func handleDeleteImage(sc *SafeConn, req DeleteImageRequest) { imageURL := req.ImageURL // Delete the actual files (webp, avif, caption) // imageURL is like /media/agent_id/images/01_closeup_front_neutral.webp relativePath := strings.TrimPrefix(imageURL, "/media/") basePath := filepath.Join(mediaDir, relativePath) baseWithoutExt := strings.TrimSuffix(basePath, filepath.Ext(basePath)) // Remove webp, avif, caption files os.Remove(basePath) // .webp os.Remove(baseWithoutExt + ".avif") // .avif os.Remove(baseWithoutExt + ".caption") // .caption // Update agent atomically var found bool updatedAgent, err := updateAgentField(req.AgentID, func(a *Agent) { newImages := []string{} for _, img := range a.Images { if img == imageURL { found = true continue } newImages = append(newImages, img) } a.Images = newImages }) if err != nil { sendError(sc, "Agent not found: "+req.AgentID) return } if !found { sendError(sc, "Image not found in agent") return } log.Printf("Deleted image %s from %s", imageURL, updatedAgent.Name) hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) sc.WriteJSON(WSMessage{Type: "image_deleted", Payload: map[string]string{"image_url": imageURL}}) } func handleDeleteVideo(sc *SafeConn, req DeleteVideoRequest) { videoURL := req.VideoURL // Strip query params for file path comparison urlWithoutQuery := strings.Split(videoURL, "?")[0] // Delete the actual video file // videoURL is like /media/agent_id/videos/01_smile_reveal.mp4?t=123 relativePath := strings.TrimPrefix(urlWithoutQuery, "/media/") fullPath := filepath.Join(mediaDir, relativePath) os.Remove(fullPath) // Update agent atomically var found bool updatedAgent, err := updateAgentField(req.AgentID, func(a *Agent) { newVideos := []string{} for _, vid := range a.Videos { // Strip query params for comparison vidWithoutQuery := strings.Split(vid, "?")[0] if vidWithoutQuery == urlWithoutQuery { found = true continue } newVideos = append(newVideos, vid) } a.Videos = newVideos }) if err != nil { sendError(sc, "Agent not found: "+req.AgentID) return } if !found { sendError(sc, "Video not found in agent") return } log.Printf("Deleted video %s from %s", videoURL, updatedAgent.Name) hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) sc.WriteJSON(WSMessage{Type: "video_deleted", Payload: map[string]string{"video_url": videoURL}}) } // checkDuplicateAgent checks if an agent with this name already exists func checkDuplicateAgent(name string) (*Agent, bool) { files, _ := os.ReadDir(agentsDir) for _, f := range files { if !strings.HasSuffix(f.Name(), ".json") { continue } data, _ := os.ReadFile(filepath.Join(agentsDir, f.Name())) var agent Agent if json.Unmarshal(data, &agent) == nil { if strings.EqualFold(agent.Name, name) { return &agent, true } } } return nil, false } func handleRegeneratePositions(sc *SafeConn, req RegeneratePositionsRequest) { // Validate positions for _, pos := range req.Positions { if pos < 1 || pos > 75 { sendError(sc, fmt.Sprintf("Invalid position %d: must be between 1 and 75", pos)) return } } if len(req.Positions) == 0 { sendError(sc, "No positions specified") return } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() // Load agent agent, err := loadAgent(req.AgentID) if err != nil { sendError(sc, "Agent not found: "+req.AgentID) return } // Create service cfg := personagen.ServiceConfig{ LaozhangAPIKey: apiKey, } service, err := personagen.NewService(ctx, cfg, &personagen.NoopProgressReporter{}) if err != nil { sendError(sc, "Failed to create service: "+err.Error()) return } // Load persona spec and anchor optimizer := storage.NewOptimizer(85, 80) store := storage.NewFilesystemStorage(mediaDir, optimizer) spec, err := store.LoadPersona(ctx, agent.ID) if err != nil { sendError(sc, "Failed to load persona: "+err.Error()) return } // Ensure ImageMatrix has 75 positions if len(spec.ImageMatrix) < 75 { spec.ImageMatrix = persona.ExtendedImageMatrix(75) } // Load anchor for consistency anchorBytes, err := store.LoadAnchor(ctx, agent.ID) if err != nil { sc.WriteJSON(WSMessage{Type: "error", Payload: map[string]string{ "message": "Anchor not found - regenerate anchor first", "code": "ANCHOR_MISSING", }}) return } service.SetAnchor(anchorBytes) // Notify that regeneration is starting sc.WriteJSON(WSMessage{Type: "positions_regenerating", Payload: map[string]interface{}{ "positions": req.Positions, "agent_id": req.AgentID, }}) // Create job for tracking job := createJob(agent, "gallery", len(req.Positions)) job.Status = "generating" updateJob(job) log.Printf("Regenerating positions %v for %s...", req.Positions, agent.Name) // Delete existing images at these positions first for _, pos := range req.Positions { deleteImageAtPosition(agent.ID, pos, store) } // Update agent to remove deleted images updatedAgent, _ := updateAgentField(agent.ID, func(a *Agent) { newImages := []string{} for _, imgURL := range a.Images { base := filepath.Base(imgURL) keep := true for _, pos := range req.Positions { prefix := fmt.Sprintf("%02d_", pos) if strings.HasPrefix(base, prefix) { keep = false break } } if keep { newImages = append(newImages, imgURL) } } a.Images = newImages }) hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) // Generate new images for each position for i, pos := range req.Positions { log.Printf("Regenerating position %d (%d/%d) for %s...", pos, i+1, len(req.Positions), agent.Name) images, err := service.GenerateImages(ctx, spec, []int{pos}, nil) if err != nil { log.Printf("Position %d regeneration failed for %s: %v", pos, agent.Name, err) continue } imgBytes := images[pos] var imgMeta *persona.ImageSpec if pos <= len(spec.ImageMatrix) { imgMeta = &spec.ImageMatrix[pos-1] } paths, err := store.SaveImage(ctx, agent.ID, pos, imgBytes, imgMeta) if err != nil { log.Printf("Failed to save regenerated image %d: %v", pos, err) continue } newImagePath := "/media/" + agent.ID + "/images/" + filepath.Base(paths.WebP) updatedAgent, err := updateAgentField(agent.ID, func(a *Agent) { a.Images = append(a.Images, newImagePath) }) if err == nil { hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) } job.Progress = int(float64(i+1) / float64(len(req.Positions)) * 100) updateJob(job) log.Printf("Position %d regenerated for %s", pos, agent.Name) } job.Status = "complete" job.Progress = 100 updateJob(job) log.Printf("Position regeneration complete for %s", agent.Name) } // deleteImageAtPosition removes image files for a specific position func deleteImageAtPosition(agentID string, position int, store storage.Storage) { imagesDir := filepath.Join(mediaDir, agentID, "images") prefix := fmt.Sprintf("%02d_", position) entries, err := os.ReadDir(imagesDir) if err != nil { return } for _, entry := range entries { if strings.HasPrefix(entry.Name(), prefix) { os.Remove(filepath.Join(imagesDir, entry.Name())) } } } func handleModifyPersona(sc *SafeConn, req ModifyPersonaRequest) { ctx := context.Background() // Load persona spec optimizer := storage.NewOptimizer(85, 80) store := storage.NewFilesystemStorage(mediaDir, optimizer) spec, err := store.LoadPersona(ctx, req.AgentID) if err != nil { sendError(sc, "Failed to load persona: "+err.Error()) return } var modified bool var description string switch req.Modification { case "tone_eye_color": modified = toneDownEyeColor(spec, req.Intensity) description = fmt.Sprintf("Eye color toned down (%s)", req.Intensity) case "align_hair_brows": modified = alignHairAndBrows(spec) description = "Brow color aligned to hair color" default: sendError(sc, "Unknown modification type: "+req.Modification) return } if !modified { sc.WriteJSON(WSMessage{Type: "persona_modified", Payload: map[string]interface{}{ "agent_id": req.AgentID, "modified": false, "description": "No changes needed", }}) return } // Save modified spec _, err = store.SavePersona(ctx, spec, storage.SaveOptions{ ID: req.AgentID, Format: storage.FormatJSON, Force: true, }) if err != nil { sendError(sc, "Failed to save modified persona: "+err.Error()) return } log.Printf("Persona modified for %s: %s", req.AgentID, description) sc.WriteJSON(WSMessage{Type: "persona_modified", Payload: map[string]interface{}{ "agent_id": req.AgentID, "modified": true, "description": description, }}) } // toneDownEyeColor replaces vivid/striking eye color descriptors with softer terms // It modifies the Visual.BiologicalIdentity.Eyes.Color or Eyes.DistinctiveFeatures func toneDownEyeColor(spec *persona.PersonaSpec, intensity string) bool { // Check both the visual spec and distinctive features eyeColor := spec.Visual.BiologicalIdentity.Eyes.Color eyeFeatures := spec.Visual.BiologicalIdentity.Eyes.DistinctiveFeatures if eyeColor == "" && eyeFeatures == "" { return false } // Define replacements based on intensity var replacements map[string]string if intensity == "subtle" { replacements = map[string]string{ "striking": "soft", "vivid": "gentle", "bright": "warm", "intense": "natural", "piercing": "calm", "electric": "muted", "brilliant": "subdued", "dazzling": "understated", "mesmerizing": "pleasant", "hypnotic": "relaxed", "captivating": "appealing", "enchanting": "nice", "extraordinary": "ordinary", } } else { // natural (default) replacements = map[string]string{ "striking": "natural", "vivid": "natural", "bright": "clear", "intense": "gentle", "piercing": "soft", "electric": "calm", "brilliant": "clear", "dazzling": "pleasant", "mesmerizing": "appealing", "hypnotic": "natural", "captivating": "nice", "enchanting": "pleasant", "extraordinary": "normal", } } modified := false // Apply to eye color description if eyeColor != "" { newColor := applyReplacements(eyeColor, replacements) if newColor != eyeColor { spec.Visual.BiologicalIdentity.Eyes.Color = newColor modified = true } } // Apply to distinctive features if eyeFeatures != "" { newFeatures := applyReplacements(eyeFeatures, replacements) if newFeatures != eyeFeatures { spec.Visual.BiologicalIdentity.Eyes.DistinctiveFeatures = newFeatures modified = true } } return modified } // applyReplacements applies case-insensitive replacements while preserving original casing func applyReplacements(original string, replacements map[string]string) string { result := original for old, new := range replacements { // Case-insensitive replacement lowerResult := strings.ToLower(result) lowerOld := strings.ToLower(old) if idx := strings.Index(lowerResult, lowerOld); idx != -1 { // Preserve casing by checking if original was capitalized if idx == 0 && len(result) > 0 && result[0] >= 'A' && result[0] <= 'Z' { new = strings.ToUpper(string(new[0])) + new[1:] } result = result[:idx] + new + result[idx+len(old):] } } return result } // alignHairAndBrows ensures DNA brow color logically matches hair color // Note: Since DNA uses categorical types, we log the mismatch but can't directly // change enum values. For visual spec, we can update string descriptions. func alignHairAndBrows(spec *persona.PersonaSpec) bool { // Get hair color from either DNA or Visual spec var hairColor string if spec.DNA != nil && spec.DNA.Face.HairColor != "" { hairColor = string(spec.DNA.Face.HairColor) } else { hairColor = spec.Visual.BiologicalIdentity.Hair.CurrentColor if hairColor == "" { hairColor = spec.Visual.BiologicalIdentity.Hair.NaturalColor } } if hairColor == "" { return false } // Map hair colors to expected brow darkness // This helps identify mismatches (e.g., black hair with blonde brows) browColorMap := map[string]string{ "black": "dark", "dark_brown": "dark brown", "dark brown": "dark brown", "brown": "brown", "light_brown": "light brown", "light brown": "light brown", "auburn": "auburn", "red": "auburn-tinted", "ginger": "ginger-tinted", "blonde": "light brown", "dirty blonde": "light brown", "platinum": "light", "gray": "gray", "grey": "grey", "white": "light gray", "silver": "gray", } // Find matching brow color hairColorLower := strings.ToLower(hairColor) var expectedBrowColor string for key, val := range browColorMap { if strings.Contains(hairColorLower, key) { expectedBrowColor = val break } } if expectedBrowColor == "" { // Default: derive from hair color expectedBrowColor = hairColorLower } // Store the expected brow color in a custom field or style signature note // Since DNA brows are categorical (shape/thickness), we add a note to the style signature signatureMakeup := spec.Visual.StyleSignature.SignatureMakeup browNote := fmt.Sprintf("brows match %s hair", expectedBrowColor) if signatureMakeup == "" { spec.Visual.StyleSignature.SignatureMakeup = browNote return true } // Check if already has brow note if strings.Contains(strings.ToLower(signatureMakeup), "brow") { return false } // Append brow note spec.Visual.StyleSignature.SignatureMakeup = signatureMakeup + ", " + browNote return true } func handleRegenerateBanner(sc *SafeConn, req RegenerateBannerRequest) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() // Load agent agent, err := loadAgent(req.AgentID) if err != nil { sendError(sc, "Agent not found: "+req.AgentID) return } // Create service cfg := personagen.ServiceConfig{ LaozhangAPIKey: apiKey, } service, err := personagen.NewService(ctx, cfg, &personagen.NoopProgressReporter{}) if err != nil { sendError(sc, "Failed to create service: "+err.Error()) return } // Load persona spec and anchor optimizer := storage.NewOptimizer(85, 80) store := storage.NewFilesystemStorage(mediaDir, optimizer) spec, err := store.LoadPersona(ctx, agent.ID) if err != nil { sendError(sc, "Failed to load persona: "+err.Error()) return } // Load anchor for consistency anchorBytes, err := store.LoadAnchor(ctx, agent.ID) if err != nil { sc.WriteJSON(WSMessage{Type: "error", Payload: map[string]string{ "message": "Anchor not found - regenerate anchor first", "code": "ANCHOR_MISSING", }}) return } service.SetAnchor(anchorBytes) // Notify that regeneration is starting sc.WriteJSON(WSMessage{Type: "banner_regenerating", Payload: map[string]interface{}{ "agent_id": req.AgentID, "style": req.Style, }}) // Apply style hint if provided if req.Style != "" { applyBannerStyleHint(spec, req.Style) } // Delete existing banner bannerDir := filepath.Join(mediaDir, agent.ID) entries, _ := os.ReadDir(bannerDir) for _, entry := range entries { if strings.HasPrefix(entry.Name(), "banner") { os.Remove(filepath.Join(bannerDir, entry.Name())) } } // Generate banner using the job function generateBannerJob(ctx, service, store, spec, agent) } // applyBannerStyleHint modifies the persona spec to hint at a banner style func applyBannerStyleHint(spec *persona.PersonaSpec, style string) { // We store the style hint in a way the banner generation can use // This could be done via ImageMatrix or a custom field // For now, we'll use the scene hint approach by modifying lifestyle context switch style { case "lifestyle": // Emphasize lifestyle/activity context if len(spec.Lifestyle.Interests.Passionate) > 0 { // Already has lifestyle context, no change needed } case "portrait": // For portrait, we want a simpler background // This would need banner-specific handling in GenerateBanner case "scenic": // Emphasize outdoor/scenic elements case "artistic": // Allow more creative/artistic interpretation } // The actual implementation depends on how GenerateBanner constructs prompts // This is a placeholder for the style hint mechanism } // handleBulkRegenerateVideos regenerates all videos for multiple agents. // This is useful after fixing accent issues to update all affected agents. func handleBulkRegenerateVideos(sc *SafeConn, req BulkRegenerateVideosRequest) { if len(req.AgentIDs) == 0 { sendError(sc, "No agent IDs provided") return } // Notify start of bulk operation sc.WriteJSON(WSMessage{Type: "bulk_video_regeneration_started", Payload: map[string]interface{}{ "agent_ids": req.AgentIDs, "total": len(req.AgentIDs), }}) log.Printf("Starting bulk video regeneration for %d agents", len(req.AgentIDs)) // Process each agent sequentially (to avoid overwhelming the video API) for i, agentID := range req.AgentIDs { agent, err := loadAgent(agentID) if err != nil { log.Printf("Agent not found: %s", agentID) sc.WriteJSON(WSMessage{Type: "bulk_video_regeneration_progress", Payload: map[string]interface{}{ "current": i + 1, "total": len(req.AgentIDs), "agent_id": agentID, "agent_name": "", "status": "error", "error": "Agent not found", }}) continue } // Delete existing videos for _, videoURL := range agent.Videos { relativePath := strings.TrimPrefix(strings.Split(videoURL, "?")[0], "/media/") fullPath := filepath.Join(mediaDir, relativePath) os.Remove(fullPath) } // Clear videos from agent updatedAgent, _ := updateAgentField(agentID, func(a *Agent) { a.Videos = []string{} }) hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) // Regenerate video for this agent ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) cfg := personagen.ServiceConfig{ LaozhangAPIKey: apiKey, } service, err := personagen.NewService(ctx, cfg, &personagen.NoopProgressReporter{}) if err != nil { cancel() log.Printf("Failed to create service for %s: %v", agent.Name, err) sc.WriteJSON(WSMessage{Type: "bulk_video_regeneration_progress", Payload: map[string]interface{}{ "current": i + 1, "total": len(req.AgentIDs), "agent_id": agentID, "agent_name": agent.Name, "status": "error", "error": err.Error(), }}) continue } optimizer := storage.NewOptimizer(85, 80) store := storage.NewFilesystemStorage(mediaDir, optimizer) spec, err := store.LoadPersona(ctx, agentID) if err != nil { cancel() log.Printf("Failed to load persona for %s: %v", agent.Name, err) sc.WriteJSON(WSMessage{Type: "bulk_video_regeneration_progress", Payload: map[string]interface{}{ "current": i + 1, "total": len(req.AgentIDs), "agent_id": agentID, "agent_name": agent.Name, "status": "error", "error": err.Error(), }}) continue } // Load anchor anchorBytes, err := store.LoadAnchor(ctx, agentID) if err != nil { cancel() log.Printf("Anchor not found for %s: %v", agent.Name, err) sc.WriteJSON(WSMessage{Type: "bulk_video_regeneration_progress", Payload: map[string]interface{}{ "current": i + 1, "total": len(req.AgentIDs), "agent_id": agentID, "agent_name": agent.Name, "status": "error", "error": "Anchor not found", }}) continue } service.SetAnchor(anchorBytes) // Notify progress sc.WriteJSON(WSMessage{Type: "bulk_video_regeneration_progress", Payload: map[string]interface{}{ "current": i + 1, "total": len(req.AgentIDs), "agent_id": agentID, "agent_name": agent.Name, "status": "generating", }}) // Generate video generateVideoJob(ctx, service, store, spec, agent) cancel() // Notify completion for this agent sc.WriteJSON(WSMessage{Type: "bulk_video_regeneration_progress", Payload: map[string]interface{}{ "current": i + 1, "total": len(req.AgentIDs), "agent_id": agentID, "agent_name": agent.Name, "status": "complete", }}) log.Printf("Video regenerated for %s (%d/%d)", agent.Name, i+1, len(req.AgentIDs)) } // Notify completion of bulk operation sc.WriteJSON(WSMessage{Type: "bulk_video_regeneration_complete", Payload: map[string]interface{}{ "total": len(req.AgentIDs), }}) log.Printf("Bulk video regeneration complete for %d agents", len(req.AgentIDs)) } func handleBulkDelete(sc *SafeConn, req BulkDeleteRequest) { // Build sets of URLs to delete (strip query params for comparison) imageURLsToDelete := make(map[string]bool) for _, url := range req.ImageURLs { imageURLsToDelete[url] = true } videoURLsToDelete := make(map[string]bool) for _, url := range req.VideoURLs { urlWithoutQuery := strings.Split(url, "?")[0] videoURLsToDelete[urlWithoutQuery] = true } var imagesDeleted, videosDeleted int // Update agent atomically and delete files updatedAgent, err := updateAgentField(req.AgentID, func(a *Agent) { // Delete image files and filter array newImages := []string{} for _, imgURL := range a.Images { if imageURLsToDelete[imgURL] { // Delete the actual files (webp, avif, caption) relativePath := strings.TrimPrefix(imgURL, "/media/") basePath := filepath.Join(mediaDir, relativePath) baseWithoutExt := strings.TrimSuffix(basePath, filepath.Ext(basePath)) os.Remove(basePath) // .webp os.Remove(baseWithoutExt + ".avif") // .avif os.Remove(baseWithoutExt + ".caption") // .caption imagesDeleted++ continue } newImages = append(newImages, imgURL) } a.Images = newImages // Delete video files and filter array newVideos := []string{} for _, vidURL := range a.Videos { vidWithoutQuery := strings.Split(vidURL, "?")[0] if videoURLsToDelete[vidWithoutQuery] { // Delete the actual video file relativePath := strings.TrimPrefix(vidWithoutQuery, "/media/") fullPath := filepath.Join(mediaDir, relativePath) os.Remove(fullPath) videosDeleted++ continue } newVideos = append(newVideos, vidURL) } a.Videos = newVideos }) if err != nil { sendError(sc, "Agent not found: "+req.AgentID) return } log.Printf("Bulk deleted %d images and %d videos from %s", imagesDeleted, videosDeleted, updatedAgent.Name) hub.Broadcast(WSMessage{Type: "agent_updated", Payload: updatedAgent}) sc.WriteJSON(WSMessage{Type: "bulk_deleted", Payload: map[string]int{ "images_deleted": imagesDeleted, "videos_deleted": videosDeleted, }}) } func createJob(agent *Agent, jobType string, total int) *MediaJob { job := &MediaJob{ ID: uuid.New().String(), AgentID: agent.ID, AgentName: agent.Name, Type: jobType, Status: "pending", Progress: 0, Total: total, } jobsMu.Lock() jobs[job.ID] = job jobsMu.Unlock() hub.Broadcast(WSMessage{Type: "job_update", Payload: job}) return job } func updateJob(job *MediaJob) { jobsMu.Lock() jobs[job.ID] = job jobsMu.Unlock() hub.Broadcast(WSMessage{Type: "job_update", Payload: job}) // Auto-remove completed/error jobs after 30 seconds if job.Status == "complete" || job.Status == "error" { go func(jobID string) { time.Sleep(30 * time.Second) jobsMu.Lock() delete(jobs, jobID) jobsMu.Unlock() hub.Broadcast(WSMessage{Type: "job_removed", Payload: map[string]string{"id": jobID}}) }(job.ID) } } func clearCompletedJobs() { jobsMu.Lock() toRemove := []string{} for id, job := range jobs { if job.Status == "complete" || job.Status == "error" { toRemove = append(toRemove, id) } } for _, id := range toRemove { delete(jobs, id) } jobsMu.Unlock() for _, id := range toRemove { hub.Broadcast(WSMessage{Type: "job_removed", Payload: map[string]string{"id": id}}) } } func extractTags(spec *persona.PersonaSpec) []string { tags := []string{} // Add archetype as a tag if spec.Psychology.DatingPersonality.Archetype != "" { tags = append(tags, strings.ToLower(spec.Psychology.DatingPersonality.Archetype)) } // Add passionate interests as tags for _, interest := range spec.Lifestyle.Interests.Passionate { if len(tags) < 8 { tags = append(tags, strings.ToLower(interest)) } } // Fill remaining slots with casual interests for _, interest := range spec.Lifestyle.Interests.Casual { if len(tags) < 8 { tags = append(tags, strings.ToLower(interest)) } } return tags } func saveAgentJSON(agent *Agent) error { data, _ := json.MarshalIndent(agent, "", " ") return os.WriteFile(filepath.Join(agentsDir, agent.ID+".json"), data, 0644) } // updateAgentField atomically reads the agent, applies the update function, and saves. // This prevents race conditions when multiple jobs update the same agent concurrently. func updateAgentField(agentID string, updateFn func(*Agent)) (*Agent, error) { agentsMu.Lock() defer agentsMu.Unlock() agent, err := loadAgent(agentID) if err != nil { return nil, err } updateFn(agent) if err := saveAgentJSON(agent); err != nil { return nil, err } return agent, nil } func loadAgent(id string) (*Agent, error) { data, err := os.ReadFile(filepath.Join(agentsDir, id+".json")) if err != nil { return nil, err } var agent Agent if err := json.Unmarshal(data, &agent); err != nil { return nil, err } return &agent, nil } func listAgents(w http.ResponseWriter, r *http.Request) { // Parse pagination params limit := 20 offset := 0 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } if o := r.URL.Query().Get("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } // Load all agents files, _ := os.ReadDir(agentsDir) allAgents := []Agent{} for _, f := range files { if !strings.HasSuffix(f.Name(), ".json") { continue } data, err := os.ReadFile(filepath.Join(agentsDir, f.Name())) if err != nil { continue } var agent Agent if json.Unmarshal(data, &agent) == nil { allAgents = append(allAgents, agent) } } // Sort by created_at descending (newest first) sort.Slice(allAgents, func(i, j int) bool { return allAgents[i].CreatedAt.After(allAgents[j].CreatedAt) }) // Apply pagination total := len(allAgents) end := offset + limit if end > total { end = total } start := offset if start > total { start = total } paged := allAgents[start:end] w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "agents": paged, "total": total, "has_more": end < total, }) } func getAgent(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/agents/") data, err := os.ReadFile(filepath.Join(agentsDir, id+".json")) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") w.Write(data) } func sendError(sc *SafeConn, msg string) { sc.WriteJSON(WSMessage{Type: "error", Payload: map[string]string{"message": msg}}) } // wsProgressReporter implements personagen.ProgressReporter for WebSocket updates type wsProgressReporter struct { send func(WSMessage) } func (r *wsProgressReporter) OnProgress(stage, message string) { r.send(WSMessage{Type: "progress", Payload: map[string]string{"stage": stage, "message": message}}) }