capsule AI-native Unix-like composition layer

src/server/internal/api/characters.go

14,675 bytes · 523 lines · capsule://quake0day/[email protected] raw on github

package api

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/cyberverse/server/internal/character"
	"github.com/cyberverse/server/internal/config"
	"gopkg.in/yaml.v3"
)

type characterResponse struct {
	*character.Character
	IdleVideoURL  string   `json:"idle_video_url,omitempty"`
	IdleVideoURLs []string `json:"idle_video_urls,omitempty"`
}

type testCharacterVoiceRequest struct {
	VoiceProvider string `json:"voice_provider"`
	VoiceType     string `json:"voice_type"`
}

type idleVideoTarget struct {
	width  int
	height int
}

func (t idleVideoTarget) valid() bool {
	return t.width > 0 && t.height > 0
}

func characterIdleVideoSizeDir(target idleVideoTarget) string {
	if !target.valid() {
		return ""
	}
	return fmt.Sprintf("%dx%d", target.width, target.height)
}

func (r *Router) currentIdleVideoTarget(ctx context.Context) idleVideoTarget {
	if r == nil || r.orch == nil {
		return idleVideoTarget{}
	}

	if !r.orch.AvatarEnabled() {
		return r.configuredIdleVideoTarget()
	}

	info, err := r.orch.AvatarInfo(ctx)
	if err != nil {
		return idleVideoTarget{}
	}

	target := idleVideoTarget{
		width:  int(info.GetOutputWidth()),
		height: int(info.GetOutputHeight()),
	}
	if !target.valid() {
		return idleVideoTarget{}
	}
	return target
}

func parseAvatarSize(raw string) (int, int) {
	raw = strings.TrimSpace(strings.ToLower(raw))
	if raw == "" {
		return 0, 0
	}
	raw = strings.ReplaceAll(raw, " ", "")
	sep := "*"
	if strings.Contains(raw, "x") {
		sep = "x"
	}
	parts := strings.Split(raw, sep)
	if len(parts) != 2 {
		return 0, 0
	}
	width, wErr := strconv.Atoi(parts[0])
	height, hErr := strconv.Atoi(parts[1])
	if wErr != nil || hErr != nil || width <= 0 || height <= 0 {
		return 0, 0
	}
	return width, height
}

func yamlIntAtPath(doc *yaml.Node, dotPath string) int {
	node, err := config.GetNodeAtPath(doc, dotPath)
	if err != nil {
		return 0
	}
	switch v := config.NodeValue(node, true).(type) {
	case int:
		return v
	case int64:
		return int(v)
	case float64:
		return int(v)
	case string:
		n, _ := strconv.Atoi(strings.TrimSpace(v))
		return n
	default:
		return 0
	}
}

func (r *Router) configuredIdleVideoTarget() idleVideoTarget {
	if r == nil || r.configPath == "" {
		return idleVideoTarget{}
	}
	model := r.configuredDefaultAvatarModel()
	if model == "" {
		return idleVideoTarget{}
	}
	doc, err := config.ReadYAMLNode(r.configPath)
	if err != nil {
		return idleVideoTarget{}
	}
	inferPath := inferParamsConfigPath(model)
	width := yamlIntAtPath(doc, inferPath+".width")
	height := yamlIntAtPath(doc, inferPath+".height")
	if width <= 0 || height <= 0 {
		if node, err := config.GetNodeAtPath(doc, inferPath+".size"); err == nil {
			if raw, ok := config.NodeValue(node, true).(string); ok {
				width, height = parseAvatarSize(raw)
			}
		}
	}
	target := idleVideoTarget{width: width, height: height}
	if !target.valid() {
		return idleVideoTarget{}
	}
	return target
}

// idleVideoURLs returns idle video URLs for the current output resolution.
func (r *Router) idleVideoURLs(characterID, imageFilename string, target idleVideoTarget) []string {
	if r.charStore == nil || characterID == "" || imageFilename == "" {
		return nil
	}
	if !target.valid() {
		return nil
	}
	imgBase := strings.TrimSuffix(imageFilename, filepath.Ext(imageFilename))
	sizeDir := characterIdleVideoSizeDir(target)
	files, err := r.charStore.ListIdleVideos(characterID, imageFilename, target.width, target.height)
	if err != nil || len(files) == 0 {
		return nil
	}

	urls := make([]string, 0, len(files))
	for _, filename := range files {
		urls = append(urls, fmt.Sprintf("/api/v1/characters/%s/idle-videos/%s/%s/%s", characterID, imgBase, sizeDir, filename))
	}
	return urls
}

// idleVideoURL returns the first idle video URL (backward compatibility).
func (r *Router) idleVideoURL(characterID, imageFilename string, target idleVideoTarget) string {
	urls := r.idleVideoURLs(characterID, imageFilename, target)
	if len(urls) == 0 {
		return ""
	}
	return urls[0]
}

func (r *Router) buildCharacterResponse(c *character.Character, target idleVideoTarget) characterResponse {
	if c == nil {
		return characterResponse{}
	}
	urls := r.idleVideoURLs(c.ID, c.ActiveImage, target)
	firstURL := ""
	if len(urls) > 0 {
		firstURL = urls[0]
	}
	return characterResponse{
		Character:     c,
		IdleVideoURL:  firstURL,
		IdleVideoURLs: urls,
	}
}

func (r *Router) handleListCharacters(w http.ResponseWriter, req *http.Request) {
	chars := r.charStore.List()
	target := r.currentIdleVideoTarget(req.Context())
	result := make([]characterResponse, 0, len(chars))
	for _, c := range chars {
		result = append(result, r.buildCharacterResponse(c, target))
	}
	writeJSON(w, http.StatusOK, result)
}

func (r *Router) handleGetCharacter(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	c, err := r.charStore.Get(id)
	if err != nil {
		writeJSON(w, http.StatusNotFound, ErrorResponse{Error: err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, r.buildCharacterResponse(c, r.currentIdleVideoTarget(req.Context())))
}

func (r *Router) handleCreateCharacter(w http.ResponseWriter, req *http.Request) {
	var c character.Character
	if err := json.NewDecoder(req.Body).Decode(&c); err != nil {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid JSON: " + err.Error()})
		return
	}
	if c.Name == "" {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "name is required"})
		return
	}

	created, err := r.charStore.Create(&c)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
		return
	}
	writeJSON(w, http.StatusCreated, r.buildCharacterResponse(created, r.currentIdleVideoTarget(req.Context())))
}

func (r *Router) handleUpdateCharacter(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	var c character.Character
	if err := json.NewDecoder(req.Body).Decode(&c); err != nil {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid JSON: " + err.Error()})
		return
	}

	updated, err := r.charStore.Update(id, &c)
	if err != nil {
		writeJSON(w, http.StatusNotFound, ErrorResponse{Error: err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, r.buildCharacterResponse(updated, r.currentIdleVideoTarget(req.Context())))
}

func (r *Router) handleDeleteCharacter(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	if err := r.charStore.Delete(id); err != nil {
		writeJSON(w, http.StatusNotFound, ErrorResponse{Error: err.Error()})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (r *Router) handleTestCharacterVoice(w http.ResponseWriter, req *http.Request) {
	var body testCharacterVoiceRequest
	if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid JSON: " + err.Error()})
		return
	}

	provider := strings.ToLower(strings.TrimSpace(body.VoiceProvider))
	voiceType := strings.TrimSpace(body.VoiceType)

	if provider == "" {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "voice_provider is required"})
		return
	}
	if voiceType == "" {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "voice_type is required"})
		return
	}
	if provider != "doubao" && provider != "qwen_omni" {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "unsupported voice_provider: " + provider})
		return
	}
	if r.orch == nil {
		writeJSON(w, http.StatusServiceUnavailable, ErrorResponse{Error: errInferenceUnavailable.Error()})
		return
	}

	ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
	defer cancel()

	providerError, err := r.orch.CheckVoice(ctx, provider, voiceType)
	if providerError != "" {
		writeJSON(w, http.StatusBadGateway, ErrorResponse{Error: providerError})
		return
	}
	if err != nil {
		if ctx.Err() == context.DeadlineExceeded {
			writeJSON(w, http.StatusServiceUnavailable, ErrorResponse{Error: "voice check timed out"})
			return
		}
		writeJSON(w, http.StatusServiceUnavailable, ErrorResponse{Error: err.Error()})
		return
	}

	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// handleUploadAvatar uploads an image to the character's images/ directory.
// Kept at POST /api/v1/characters/{id}/avatar for frontend compatibility.
func (r *Router) handleUploadAvatar(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	if _, err := r.charStore.Get(id); err != nil {
		writeJSON(w, http.StatusNotFound, ErrorResponse{Error: err.Error()})
		return
	}

	if err := req.ParseMultipartForm(10 << 20); err != nil {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "file too large"})
		return
	}

	file, handler, err := req.FormFile("avatar")
	if err != nil {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "avatar file required"})
		return
	}
	defer file.Close()

	ext := filepath.Ext(handler.Filename)
	if ext == "" {
		ext = ".png"
	}

	imgDir := r.charStore.ImagesDir(id)
	if imgDir == "" {
		writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "character directory not found"})
		return
	}
	if err := os.MkdirAll(imgDir, 0755); err != nil {
		log.Printf("Failed to create images dir: %v", err)
		writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "server error"})
		return
	}

	baseName := r.charStore.NextImageFilename(id)
	filename := baseName + ext
	destPath := filepath.Join(imgDir, filename)

	dest, err := os.Create(destPath)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to save file"})
		return
	}
	defer dest.Close()

	if _, err := io.Copy(dest, file); err != nil {
		writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to write file"})
		return
	}

	// Add image to character's image list
	info := character.ImageInfo{
		Filename: filename,
		OrigName: handler.Filename,
		AddedAt:  fmt.Sprintf("%d", handler.Size),
	}
	if err := r.charStore.AddImage(id, info); err != nil {
		log.Printf("Failed to add image record: %v", err)
	}

	c, _ := r.charStore.Get(id)
	writeJSON(w, http.StatusOK, map[string]string{
		"path":     c.AvatarImage,
		"filename": filename,
	})
}

// handleListImages returns all images for a character.
func (r *Router) handleListImages(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	imgs, err := r.charStore.ListImages(id)
	if err != nil {
		writeJSON(w, http.StatusNotFound, ErrorResponse{Error: err.Error()})
		return
	}

	// Add URL field for each image
	type imageResp struct {
		character.ImageInfo
		URL string `json:"url"`
	}
	result := make([]imageResp, len(imgs))
	for i, img := range imgs {
		result[i] = imageResp{
			ImageInfo: img,
			URL:       fmt.Sprintf("/api/v1/characters/%s/images/%s", id, img.Filename),
		}
	}
	writeJSON(w, http.StatusOK, result)
}

// handleGetCharacterImage serves an image file from the character's images/ directory.
func (r *Router) handleGetCharacterImage(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	filename := req.PathValue("filename")

	if filename == "" || filename != filepath.Base(filename) || strings.Contains(filename, "..") {
		http.NotFound(w, req)
		return
	}

	imgDir := r.charStore.ImagesDir(id)
	if imgDir == "" {
		http.NotFound(w, req)
		return
	}

	imgPath := filepath.Join(imgDir, filename)
	if _, err := os.Stat(imgPath); err != nil {
		http.NotFound(w, req)
		return
	}

	http.ServeFile(w, req, imgPath)
}

// handleGetIdleVideo serves a cached idle MP4 from the character's idle_videos/{imgbase}/ directory.
func (r *Router) handleGetIdleVideo(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	imgbase := req.PathValue("imgbase")
	variant := req.PathValue("variant")
	filename := req.PathValue("filename")

	// Validate path components to prevent traversal
	parts := []string{imgbase, filename}
	if variant != "" {
		parts = append(parts, variant)
	}
	for _, part := range parts {
		if part == "" || part != filepath.Base(part) || strings.Contains(part, "..") {
			http.NotFound(w, req)
			return
		}
	}

	videoDir := r.charStore.IdleVideosDir(id)
	if videoDir == "" {
		http.NotFound(w, req)
		return
	}

	videoPath := filepath.Join(videoDir, imgbase)
	if variant != "" {
		videoPath = filepath.Join(videoPath, variant)
	}
	videoPath = filepath.Join(videoPath, filename)
	if _, err := os.Stat(videoPath); err != nil {
		http.NotFound(w, req)
		return
	}

	http.ServeFile(w, req, videoPath)
}

// handleDeleteImage removes an image from a character.
func (r *Router) handleDeleteImage(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	filename := req.PathValue("filename")

	if filename == "" || filename != filepath.Base(filename) || strings.Contains(filename, "..") {
		writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid filename"})
		return
	}

	// Delete file from disk
	imgDir := r.charStore.ImagesDir(id)
	if imgDir == "" {
		writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "character not found"})
		return
	}
	imgPath := filepath.Join(imgDir, filename)
	os.Remove(imgPath)

	// Remove from character record
	if err := r.charStore.RemoveImage(id, filename); err != nil {
		writeJSON(w, http.StatusNotFound, ErrorResponse{Error: err.Error()})
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// handleActivateImage sets a specific image as the character's active avatar.
func (r *Router) handleActivateImage(w http.ResponseWriter, req *http.Request) {
	id := req.PathValue("id")
	filename := req.PathValue("filename")

	if err := r.charStore.ActivateImage(id, filename); err != nil {
		writeJSON(w, http.StatusNotFound, ErrorResponse{Error: err.Error()})
		return
	}

	c, _ := r.charStore.Get(id)
	target := r.currentIdleVideoTarget(req.Context())
	writeJSON(w, http.StatusOK, map[string]any{
		"active_image":    c.ActiveImage,
		"avatar_image":    c.AvatarImage,
		"idle_video_url":  r.idleVideoURL(c.ID, c.ActiveImage, target),
		"idle_video_urls": r.idleVideoURLs(c.ID, c.ActiveImage, target),
	})
}

// handleGetAvatar serves avatar files (backward compatibility for old /api/v1/avatars/{filename} URLs).
func (r *Router) handleGetAvatar(w http.ResponseWriter, req *http.Request) {
	filename := req.PathValue("filename")
	if filename == "" || filename != filepath.Base(filename) || strings.Contains(filename, "..") {
		http.NotFound(w, req)
		return
	}

	// Legacy path: look in old data/avatars/ directory
	avatarPath := filepath.Join(filepath.Dir(r.charStore.BaseDir()), "avatars", filename)
	if _, err := os.Stat(avatarPath); err != nil {
		http.NotFound(w, req)
		return
	}

	http.ServeFile(w, req, avatarPath)
}