rdev/ideas/aeres/main.go
jordan d69da6d627 feat: add structured logging infrastructure and SDLC extensions
Major changes:
- Add internal/logging package with field constants, context propagation,
  sensitive data auto-redaction, and per-component log levels
- Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.)
- Extend SDLC with callback handlers, generate endpoints, and executor
- Add new cookbook trees for aeries and slackpath progression
- Add skeleton templates for queue, realtime, and microservices
- Add worker component template with async job processing
- Refactor services and handlers to use new logging infrastructure
- Split component.go into component_infra.go and component_listing.go

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:56:04 -07:00

1906 lines
52 KiB
Go

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}})
}