Files
libnovel/backend/internal/backend/handlers_image.go
root e399b1ce01
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Failing after 32s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
feat: admin UX overhaul — status filters, retry/cancel, mobile cards, i18n, shelf pre-populate
- Admin layout: SVG icons, active highlight, divider between nav sections
- Scrape page: status filter pills with counts, text + status combined search
- Audio page: status filter pills, cancel jobs, retry failed jobs, mobile cards for cache tab
- Translation page: status filter pills (incl. cancelled), cancel + retry jobs, mobile cancel/retry cards, i18n for all labels
- AI Jobs page: fix concurrent cancel (Set instead of single slot), per-job cancel errors inline, full mobile card layout, i18n title/heading
- Text-gen page: tagline editable input + copy, warnings copy, i18n title/heading
- Book page: chapter cover Save button, audio monitor link, currentShelf pre-populated from server
- pocketbase.ts: add getBookShelf(), shelf field on UserLibraryEntry
- New API route: POST /api/admin/translation/bulk (proxy for translation retry)
- i18n: 15 new admin_translation_*, admin_ai_jobs_*, admin_text_gen_* keys across all 5 locales
2026-04-08 18:30:35 +05:00

646 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backend
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// handleAdminImageGenModels handles GET /api/admin/image-gen/models.
// Returns the list of supported Cloudflare AI image generation models.
func (s *Server) handleAdminImageGenModels(w http.ResponseWriter, r *http.Request) {
if s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
models := s.deps.ImageGen.Models()
writeJSON(w, 0, map[string]any{"models": models})
}
// imageGenRequest is the JSON body for POST /api/admin/image-gen.
type imageGenRequest struct {
// Prompt is the text description of the desired image.
Prompt string `json:"prompt"`
// Model is the CF Workers AI model ID (e.g. "@cf/black-forest-labs/flux-2-dev").
// Defaults to the recommended model for the given type.
Model string `json:"model"`
// Type is either "cover" or "chapter".
Type string `json:"type"`
// Slug is the book slug. Required for cover; required for chapter.
Slug string `json:"slug"`
// Chapter number (1-based). Required when type == "chapter".
Chapter int `json:"chapter"`
// ReferenceImageB64 is an optional base64-encoded PNG/JPEG reference image.
// When present the img2img path is used.
ReferenceImageB64 string `json:"reference_image_b64"`
// NumSteps overrides inference steps (default 20).
NumSteps int `json:"num_steps"`
// Width / Height override output dimensions (0 = model default).
Width int `json:"width"`
Height int `json:"height"`
// Guidance overrides prompt guidance scale (0 = model default).
Guidance float64 `json:"guidance"`
// Strength for img2img: 0.01.0, default 0.75.
Strength float64 `json:"strength"`
// SaveToCover when true stores the result as the book cover in MinIO
// (overwriting any existing cover) and sets the book's cover URL.
// Only valid when type == "cover".
SaveToCover bool `json:"save_to_cover"`
}
// imageGenResponse is the JSON body returned by POST /api/admin/image-gen.
type imageGenResponse struct {
// ImageB64 is the generated image as a base64-encoded PNG string.
ImageB64 string `json:"image_b64"`
// ContentType is "image/png" or "image/jpeg".
ContentType string `json:"content_type"`
// Saved indicates whether the image was persisted to MinIO.
Saved bool `json:"saved"`
// CoverURL is the URL the cover is now served from (only set when Saved==true).
CoverURL string `json:"cover_url,omitempty"`
// Model is the model that was used.
Model string `json:"model"`
// Bytes is the raw image size in bytes.
Bytes int `json:"bytes"`
}
// handleAdminImageGen handles POST /api/admin/image-gen.
//
// Generates an image using Cloudflare Workers AI and optionally stores it.
// Multipart/form-data is also accepted so the reference image can be uploaded
// directly; otherwise the reference is expected as base64 JSON.
func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) {
if s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
var req imageGenRequest
var refImageData []byte
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
// Multipart: parse JSON fields from a "json" part + optional "reference" file part.
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
return
}
if jsonPart := r.FormValue("json"); jsonPart != "" {
if err := json.Unmarshal([]byte(jsonPart), &req); err != nil {
jsonError(w, http.StatusBadRequest, "parse json field: "+err.Error())
return
}
}
if f, _, err := r.FormFile("reference"); err == nil {
defer f.Close()
refImageData, _ = io.ReadAll(f)
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.ReferenceImageB64 != "" {
var decErr error
refImageData, decErr = base64.StdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
// Try std without padding
refImageData, decErr = base64.RawStdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
jsonError(w, http.StatusBadRequest, "decode reference_image_b64: "+decErr.Error())
return
}
}
}
}
if strings.TrimSpace(req.Prompt) == "" {
jsonError(w, http.StatusBadRequest, "prompt is required")
return
}
if req.Type != "cover" && req.Type != "chapter" {
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Type == "chapter" && req.Chapter <= 0 {
jsonError(w, http.StatusBadRequest, "chapter must be > 0 when type is chapter")
return
}
// Resolve model
model := cfai.ImageModel(req.Model)
if model == "" {
if req.Type == "cover" {
model = cfai.DefaultImageModel
} else {
model = cfai.ImageModelFlux2Klein4B
}
}
imgReq := cfai.ImageRequest{
Prompt: req.Prompt,
Model: model,
NumSteps: req.NumSteps,
Width: req.Width,
Height: req.Height,
Guidance: req.Guidance,
Strength: req.Strength,
}
s.deps.Log.Info("admin: image gen requested",
"type", req.Type, "slug", req.Slug, "chapter", req.Chapter,
"model", model, "has_reference", len(refImageData) > 0)
var imgData []byte
var genErr error
if len(refImageData) > 0 {
imgData, genErr = s.deps.ImageGen.GenerateImageFromReference(r.Context(), imgReq, refImageData)
} else {
imgData, genErr = s.deps.ImageGen.GenerateImage(r.Context(), imgReq)
}
if genErr != nil {
s.deps.Log.Error("admin: image gen failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "image generation failed: "+genErr.Error())
return
}
contentType := sniffImageContentType(imgData)
// ── Optional persistence ──────────────────────────────────────────────────
var saved bool
var coverURL string
if req.SaveToCover && req.Type == "cover" && s.deps.CoverStore != nil {
if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil {
s.deps.Log.Error("admin: save generated cover failed", "slug", req.Slug, "err", err)
// Non-fatal: still return the image
} else {
saved = true
coverURL = fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug)
s.deps.Log.Info("admin: generated cover saved", "slug", req.Slug, "bytes", len(imgData))
}
}
// Encode result as base64
b64 := base64.StdEncoding.EncodeToString(imgData)
writeJSON(w, 0, imageGenResponse{
ImageB64: b64,
ContentType: contentType,
Saved: saved,
CoverURL: coverURL,
Model: string(model),
Bytes: len(imgData),
})
}
// saveCoverRequest is the JSON body for POST /api/admin/image-gen/save-cover.
type saveCoverRequest struct {
// Slug is the book slug whose cover should be overwritten.
Slug string `json:"slug"`
// ImageB64 is the base64-encoded image bytes (PNG or JPEG).
ImageB64 string `json:"image_b64"`
}
// handleAdminImageGenSaveCover handles POST /api/admin/image-gen/save-cover.
//
// Accepts a pre-generated image as base64 and stores it as the book cover in
// MinIO, replacing the existing one. Does not call Cloudflare AI at all.
func (s *Server) handleAdminImageGenSaveCover(w http.ResponseWriter, r *http.Request) {
if s.deps.CoverStore == nil {
jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
return
}
var req saveCoverRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.ImageB64 == "" {
jsonError(w, http.StatusBadRequest, "image_b64 is required")
return
}
imgData, err := base64.StdEncoding.DecodeString(req.ImageB64)
if err != nil {
imgData, err = base64.RawStdEncoding.DecodeString(req.ImageB64)
if err != nil {
jsonError(w, http.StatusBadRequest, "decode image_b64: "+err.Error())
return
}
}
contentType := sniffImageContentType(imgData)
if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil {
s.deps.Log.Error("admin: save-cover failed", "slug", req.Slug, "err", err)
jsonError(w, http.StatusInternalServerError, "save cover: "+err.Error())
return
}
s.deps.Log.Info("admin: cover saved via image-gen", "slug", req.Slug, "bytes", len(imgData))
writeJSON(w, 0, map[string]any{
"saved": true,
"cover_url": fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug),
"bytes": len(imgData),
})
}
// sniffImageContentType returns the MIME type of the image bytes.
func sniffImageContentType(data []byte) string {
if len(data) >= 4 {
// PNG: 0x89 P N G
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4e && data[3] == 0x47 {
return "image/png"
}
// JPEG: FF D8 FF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
// WebP: RIFF....WEBP
if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
return "image/webp"
}
}
return "image/png"
}
// saveChapterImageRequest is the JSON body for POST /api/admin/image-gen/save-chapter-image.
type saveChapterImageRequest struct {
// Slug is the book slug.
Slug string `json:"slug"`
// Chapter is the 1-based chapter number.
Chapter int `json:"chapter"`
// ImageB64 is the base64-encoded image bytes (PNG or JPEG).
ImageB64 string `json:"image_b64"`
}
// handleAdminImageGenSaveChapterImage handles POST /api/admin/image-gen/save-chapter-image.
//
// Accepts a pre-generated image as base64 and stores it as the chapter illustration
// in MinIO, replacing the existing one if present. Does not call Cloudflare AI.
func (s *Server) handleAdminImageGenSaveChapterImage(w http.ResponseWriter, r *http.Request) {
if s.deps.ChapterImageStore == nil {
jsonError(w, http.StatusServiceUnavailable, "chapter image store not configured")
return
}
var req saveChapterImageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Chapter <= 0 {
jsonError(w, http.StatusBadRequest, "chapter must be > 0")
return
}
if req.ImageB64 == "" {
jsonError(w, http.StatusBadRequest, "image_b64 is required")
return
}
imgData, err := base64.StdEncoding.DecodeString(req.ImageB64)
if err != nil {
imgData, err = base64.RawStdEncoding.DecodeString(req.ImageB64)
if err != nil {
jsonError(w, http.StatusBadRequest, "decode image_b64: "+err.Error())
return
}
}
contentType := sniffImageContentType(imgData)
if err := s.deps.ChapterImageStore.PutChapterImage(r.Context(), req.Slug, req.Chapter, imgData, contentType); err != nil {
s.deps.Log.Error("admin: save-chapter-image failed", "slug", req.Slug, "chapter", req.Chapter, "err", err)
jsonError(w, http.StatusInternalServerError, "save chapter image: "+err.Error())
return
}
s.deps.Log.Info("admin: chapter image saved", "slug", req.Slug, "chapter", req.Chapter, "bytes", len(imgData))
writeJSON(w, 0, map[string]any{
"saved": true,
"image_url": fmt.Sprintf("/api/chapter-image/novelfire.net/%s/%d", req.Slug, req.Chapter),
"bytes": len(imgData),
})
}
// handleHeadChapterImage handles HEAD /api/chapter-image/{domain}/{slug}/{n}.
//
// Returns 200 when an image exists for this chapter, 404 otherwise.
// Used by the SSR loader to check existence without downloading the full image.
func (s *Server) handleHeadChapterImage(w http.ResponseWriter, r *http.Request) {
if s.deps.ChapterImageStore == nil {
w.WriteHeader(http.StatusNotFound)
return
}
slug := r.PathValue("slug")
nStr := r.PathValue("n")
n, err := strconv.Atoi(nStr)
if err != nil || n <= 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
if s.deps.ChapterImageStore.ChapterImageExists(r.Context(), slug, n) {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusNotFound)
}
}
// handleGetChapterImage handles GET /api/chapter-image/{domain}/{slug}/{n}.
//
// Serves the stored chapter illustration directly from MinIO.
// Returns 404 when no image has been saved for this chapter.
func (s *Server) handleGetChapterImage(w http.ResponseWriter, r *http.Request) {
if s.deps.ChapterImageStore == nil {
http.NotFound(w, r)
return
}
slug := r.PathValue("slug")
nStr := r.PathValue("n")
n, err := strconv.Atoi(nStr)
if err != nil || n <= 0 {
jsonError(w, http.StatusBadRequest, "invalid chapter number")
return
}
data, contentType, ok, err := s.deps.ChapterImageStore.GetChapterImage(r.Context(), slug, n)
if err != nil {
s.deps.Log.Error("chapter-image: get failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusInternalServerError, "could not retrieve chapter image")
return
}
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// handleAdminImageGenAsync handles POST /api/admin/image-gen/async.
//
// Fire-and-forget variant: validates the request, creates an ai_job record of
// kind "image-gen", spawns a background goroutine, and returns HTTP 202 with
// {job_id} immediately. The goroutine calls Cloudflare AI, stores the result
// as base64 in the job payload, and marks the job done/failed when finished.
//
// The admin can then review the result via the ai-jobs page and approve
// (save as cover) or reject (discard) the image.
func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request) {
if s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
var req imageGenRequest
var refImageData []byte
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
return
}
if jsonPart := r.FormValue("json"); jsonPart != "" {
if err := json.Unmarshal([]byte(jsonPart), &req); err != nil {
jsonError(w, http.StatusBadRequest, "parse json field: "+err.Error())
return
}
}
if f, _, err := r.FormFile("reference"); err == nil {
defer f.Close()
refImageData, _ = io.ReadAll(f)
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.ReferenceImageB64 != "" {
var decErr error
refImageData, decErr = base64.StdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
refImageData, decErr = base64.RawStdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
jsonError(w, http.StatusBadRequest, "decode reference_image_b64: "+decErr.Error())
return
}
}
}
}
if strings.TrimSpace(req.Prompt) == "" {
jsonError(w, http.StatusBadRequest, "prompt is required")
return
}
if req.Type != "cover" && req.Type != "chapter" {
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Type == "chapter" && req.Chapter <= 0 {
jsonError(w, http.StatusBadRequest, "chapter must be > 0 when type is chapter")
return
}
// Resolve model.
model := cfai.ImageModel(req.Model)
if model == "" {
if req.Type == "cover" {
model = cfai.DefaultImageModel
} else {
model = cfai.ImageModelFlux2Klein4B
}
}
// Encode request params as job payload so the UI can reconstruct context.
type jobParams struct {
Prompt string `json:"prompt"`
Type string `json:"type"`
Chapter int `json:"chapter,omitempty"`
NumSteps int `json:"num_steps,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Guidance float64 `json:"guidance,omitempty"`
Strength float64 `json:"strength,omitempty"`
HasRef bool `json:"has_ref,omitempty"`
}
paramsJSON, _ := json.Marshal(jobParams{
Prompt: req.Prompt,
Type: req.Type,
Chapter: req.Chapter,
NumSteps: req.NumSteps,
Width: req.Width,
Height: req.Height,
Guidance: req.Guidance,
Strength: req.Strength,
HasRef: len(refImageData) > 0,
})
jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "image-gen",
Slug: req.Slug,
Status: domain.TaskStatusPending,
Model: string(model),
Payload: string(paramsJSON),
Started: time.Now(),
})
if createErr != nil {
jsonError(w, http.StatusInternalServerError, "create ai job: "+createErr.Error())
return
}
jobCtx, jobCancel := context.WithCancel(context.Background())
registerCancelJob(jobID, jobCancel)
// Mark running before returning.
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(domain.TaskStatusRunning),
})
s.deps.Log.Info("admin: image-gen async started",
"job_id", jobID, "slug", req.Slug, "type", req.Type, "model", model)
// Capture locals for the goroutine.
store := s.deps.AIJobStore
imageGen := s.deps.ImageGen
coverStore := s.deps.CoverStore
logger := s.deps.Log
capturedReq := req
capturedModel := model
capturedRefImage := refImageData
go func() {
defer deregisterCancelJob(jobID)
defer jobCancel()
if jobCtx.Err() != nil {
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusCancelled),
"finished": time.Now().Format(time.RFC3339),
})
return
}
imgReq := cfai.ImageRequest{
Prompt: capturedReq.Prompt,
Model: capturedModel,
NumSteps: capturedReq.NumSteps,
Width: capturedReq.Width,
Height: capturedReq.Height,
Guidance: capturedReq.Guidance,
Strength: capturedReq.Strength,
}
var imgData []byte
var genErr error
if len(capturedRefImage) > 0 {
imgData, genErr = imageGen.GenerateImageFromReference(jobCtx, imgReq, capturedRefImage)
} else {
imgData, genErr = imageGen.GenerateImage(jobCtx, imgReq)
}
if genErr != nil {
logger.Error("admin: image-gen async failed", "job_id", jobID, "err", genErr)
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusFailed),
"error_message": genErr.Error(),
"finished": time.Now().Format(time.RFC3339),
})
return
}
contentType := sniffImageContentType(imgData)
b64 := base64.StdEncoding.EncodeToString(imgData)
// Build result payload: include the original params + the generated image.
type resultPayload struct {
Prompt string `json:"prompt"`
Type string `json:"type"`
Chapter int `json:"chapter,omitempty"`
ContentType string `json:"content_type"`
ImageB64 string `json:"image_b64"`
Bytes int `json:"bytes"`
NumSteps int `json:"num_steps,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Guidance float64 `json:"guidance,omitempty"`
}
resultJSON, _ := json.Marshal(resultPayload{
Prompt: capturedReq.Prompt,
Type: capturedReq.Type,
Chapter: capturedReq.Chapter,
ContentType: contentType,
ImageB64: b64,
Bytes: len(imgData),
NumSteps: capturedReq.NumSteps,
Width: capturedReq.Width,
Height: capturedReq.Height,
Guidance: capturedReq.Guidance,
})
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusDone),
"items_done": 1,
"items_total": 1,
"payload": string(resultJSON),
"finished": time.Now().Format(time.RFC3339),
})
logger.Info("admin: image-gen async done",
"job_id", jobID, "slug", capturedReq.Slug,
"bytes", len(imgData), "content_type", contentType)
// Suppress unused variable warning for coverStore when SaveToCover is false.
_ = coverStore
}()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}