From 75e6a870d339575967fa6bb9ccb5a9963903ad66 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Apr 2026 19:46:59 +0500 Subject: [PATCH] feat: async image-gen and description jobs with review panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add POST /api/admin/image-gen/async: fire-and-forget image generation that stores the result (base64) in an ai_job payload and returns 202 immediately — no more 60-120s blocking on FLUX models - Add POST /api/admin/text-gen/description/async: same pattern for book description generation - Register both new routes in server.go - Rewrite image-gen admin page to use the async path (submit → redirect to AI Jobs for monitoring) - Extend ai-jobs page with Review panels for image-gen jobs (show image, Save as cover / Download / Discard) and description jobs (diff old vs new, editable textarea, Apply / Discard) --- backend/internal/backend/handlers_image.go | 231 ++++++++ backend/internal/backend/handlers_textgen.go | 158 +++++ backend/internal/backend/server.go | 2 + ui/src/routes/admin/ai-jobs/+page.svelte | 591 ++++++++++++++----- ui/src/routes/admin/image-gen/+page.svelte | 353 +++-------- 5 files changed, 931 insertions(+), 404 deletions(-) diff --git a/backend/internal/backend/handlers_image.go b/backend/internal/backend/handlers_image.go index 99ee8f1..49de025 100644 --- a/backend/internal/backend/handlers_image.go +++ b/backend/internal/backend/handlers_image.go @@ -1,14 +1,17 @@ package backend import ( + "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" + "time" "github.com/libnovel/backend/internal/cfai" + "github.com/libnovel/backend/internal/domain" ) // handleAdminImageGenModels handles GET /api/admin/image-gen/models. @@ -288,3 +291,231 @@ func sniffImageContentType(data []byte) string { } return "image/png" } + +// 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}) +} diff --git a/backend/internal/backend/handlers_textgen.go b/backend/internal/backend/handlers_textgen.go index 313d552..4aa4a2d 100644 --- a/backend/internal/backend/handlers_textgen.go +++ b/backend/internal/backend/handlers_textgen.go @@ -801,3 +801,161 @@ func (s *Server) handleAdminTextGenApplyDescription(w http.ResponseWriter, r *ht s.deps.Log.Info("admin: book description applied", "slug", req.Slug) writeJSON(w, 0, map[string]any{"updated": true}) } + +// handleAdminTextGenDescriptionAsync handles POST /api/admin/text-gen/description/async. +// +// Fire-and-forget variant: validates inputs, creates an ai_job record of kind +// "description", spawns a background goroutine that calls the LLM, stores the +// old/new description in the job payload, and marks the job done/failed. +// Returns HTTP 202 with {job_id} immediately. +func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *http.Request) { + if s.deps.TextGen == nil { + jsonError(w, http.StatusServiceUnavailable, "text 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 textGenDescriptionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + + // Load current metadata eagerly so we can fail fast if the book is missing. + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + model := cfai.TextModel(req.Model) + if model == "" { + model = cfai.DefaultTextModel + } + + instructions := strings.TrimSpace(req.Instructions) + if instructions == "" { + instructions = "Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging." + } + + // Encode the initial params (without result) as the starting payload. + type initPayload struct { + Instructions string `json:"instructions"` + OldDescription string `json:"old_description"` + } + initJSON, _ := json.Marshal(initPayload{ + Instructions: instructions, + OldDescription: meta.Summary, + }) + + jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{ + Kind: "description", + Slug: req.Slug, + Status: domain.TaskStatusPending, + Model: string(model), + Payload: string(initJSON), + 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) + + _ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{ + "status": string(domain.TaskStatusRunning), + }) + + s.deps.Log.Info("admin: text-gen description async started", + "job_id", jobID, "slug", req.Slug, "model", model) + + // Capture locals. + store := s.deps.AIJobStore + textGen := s.deps.TextGen + logger := s.deps.Log + capturedMeta := meta + capturedModel := model + capturedInstructions := instructions + capturedMaxTokens := req.MaxTokens + + 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 + } + + systemPrompt := `You are a book description writer for a web novel platform. ` + + `Given a book's title, author, genres, and current description, write an improved ` + + `description that accurately captures the story. ` + + `Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.` + + userPrompt := fmt.Sprintf( + "Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s", + capturedMeta.Title, + capturedMeta.Author, + strings.Join(capturedMeta.Genres, ", "), + capturedMeta.Status, + capturedMeta.Summary, + capturedInstructions, + ) + + newDesc, genErr := textGen.Generate(jobCtx, cfai.TextRequest{ + Model: capturedModel, + Messages: []cfai.TextMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + }, + MaxTokens: capturedMaxTokens, + }) + if genErr != nil { + logger.Error("admin: text-gen description 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 + } + + type resultPayload struct { + Instructions string `json:"instructions"` + OldDescription string `json:"old_description"` + NewDescription string `json:"new_description"` + } + resultJSON, _ := json.Marshal(resultPayload{ + Instructions: capturedInstructions, + OldDescription: capturedMeta.Summary, + NewDescription: strings.TrimSpace(newDesc), + }) + + _ = 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: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug) + }() + + writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID}) +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 0063f4a..eec7212 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -201,6 +201,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { // Admin image generation endpoints mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels) mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen) + mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync) mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover) // Admin text generation endpoints (chapter names + book description) @@ -209,6 +210,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync) mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames) mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription) + mux.HandleFunc("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync) mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription) // Admin catalogue enrichment endpoints diff --git a/ui/src/routes/admin/ai-jobs/+page.svelte b/ui/src/routes/admin/ai-jobs/+page.svelte index 7f4962a..4b501d7 100644 --- a/ui/src/routes/admin/ai-jobs/+page.svelte +++ b/ui/src/routes/admin/ai-jobs/+page.svelte @@ -85,7 +85,8 @@ new_title: string; } - interface ReviewState { + interface ChapterNamesReview { + kind: 'chapter-names'; jobId: string; slug: string; pattern: string; @@ -97,39 +98,160 @@ applyDone: boolean; } + // ── Review (image-gen jobs) ─────────────────────────────────────────────────── + + interface ImageGenReview { + kind: 'image-gen'; + jobId: string; + slug: string; + imageType: string; + prompt: string; + imageSrc: string; + contentType: string; + bytes: number; + loading: boolean; + error: string; + saving: boolean; + saveError: string; + savedUrl: string; + } + + // ── Review (description jobs) ───────────────────────────────────────────────── + + interface DescriptionReview { + kind: 'description'; + jobId: string; + slug: string; + instructions: string; + oldDescription: string; + newDescription: string; + loading: boolean; + error: string; + applying: boolean; + applyError: string; + applyDone: boolean; + } + + type ReviewState = ChapterNamesReview | ImageGenReview | DescriptionReview; + let review = $state(null); + // ── Open review ─────────────────────────────────────────────────────────────── + async function openReview(job: AIJob) { - review = { - jobId: job.id, - slug: job.slug, - pattern: '', - titles: [], - loading: true, - error: '', - applying: false, - applyError: '', - applyDone: false - }; + if (job.kind === 'chapter-names') { + const r: ChapterNamesReview = { + kind: 'chapter-names', + jobId: job.id, + slug: job.slug, + pattern: '', + titles: [], + loading: true, + error: '', + applying: false, + applyError: '', + applyDone: false + }; + review = r; - try { - const res = await fetch(`/api/admin/ai-jobs/${job.id}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - - let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {}; try { - payload = JSON.parse(data.payload ?? '{}'); - } catch { - // ignore - } + const res = await fetch(`/api/admin/ai-jobs/${job.id}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); - review.pattern = payload.pattern ?? ''; - review.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t })); - review.loading = false; - } catch (e) { - review.loading = false; - review.error = String(e); + let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {}; + try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ } + + r.pattern = payload.pattern ?? ''; + r.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t })); + r.loading = false; + } catch (e) { + r.loading = false; + r.error = String(e); + } + } else if (job.kind === 'image-gen') { + const r: ImageGenReview = { + kind: 'image-gen', + jobId: job.id, + slug: job.slug, + imageType: '', + prompt: '', + imageSrc: '', + contentType: 'image/png', + bytes: 0, + loading: true, + error: '', + saving: false, + saveError: '', + savedUrl: '' + }; + review = r; + + try { + const res = await fetch(`/api/admin/ai-jobs/${job.id}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + let payload: { + prompt?: string; + type?: string; + content_type?: string; + image_b64?: string; + bytes?: number; + } = {}; + try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ } + + if (!payload.image_b64) { + r.error = 'No image in job payload.'; + r.loading = false; + return; + } + r.imageType = payload.type ?? 'cover'; + r.prompt = payload.prompt ?? ''; + r.contentType = payload.content_type ?? 'image/png'; + r.bytes = payload.bytes ?? 0; + r.imageSrc = `data:${r.contentType};base64,${payload.image_b64}`; + r.loading = false; + } catch (e) { + r.loading = false; + r.error = String(e); + } + } else if (job.kind === 'description') { + const r: DescriptionReview = { + kind: 'description', + jobId: job.id, + slug: job.slug, + instructions: '', + oldDescription: '', + newDescription: '', + loading: true, + error: '', + applying: false, + applyError: '', + applyDone: false + }; + review = r; + + try { + const res = await fetch(`/api/admin/ai-jobs/${job.id}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + let payload: { + instructions?: string; + old_description?: string; + new_description?: string; + } = {}; + try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ } + + r.instructions = payload.instructions ?? ''; + r.oldDescription = payload.old_description ?? ''; + r.newDescription = payload.new_description ?? ''; + r.loading = false; + } catch (e) { + r.loading = false; + r.error = String(e); + } } } @@ -137,8 +259,10 @@ review = null; } - async function applyReview() { - if (!review || review.applying) return; + // ── Apply chapter names ─────────────────────────────────────────────────────── + + async function applyChapterNames() { + if (review?.kind !== 'chapter-names' || review.applying) return; review.applying = true; review.applyError = ''; review.applyDone = false; @@ -163,16 +287,70 @@ } } - // ── Helpers ─────────────────────────────────────────────────────────────────── - function statusColor(status: string) { - if (status === 'done') return 'text-green-400'; - if (status === 'running') return 'text-(--color-brand) animate-pulse'; - if (status === 'pending') return 'text-sky-400 animate-pulse'; - if (status === 'failed') return 'text-(--color-danger)'; - if (status === 'cancelled') return 'text-(--color-muted)'; - return 'text-(--color-text)'; + // ── Save image as cover ─────────────────────────────────────────────────────── + + async function saveImageAsCover() { + if (review?.kind !== 'image-gen' || review.saving) return; + review.saving = true; + review.saveError = ''; + + const b64 = review.imageSrc.split(',')[1]; + try { + const res = await fetch('/api/admin/image-gen/save-cover', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ slug: review.slug, image_b64: b64 }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + review.saveError = body.error ?? `Error ${res.status}`; + } else { + review.savedUrl = body.cover_url ?? `/api/cover/novelfire.net/${review.slug}`; + } + } catch { + review.saveError = 'Network error.'; + } finally { + review.saving = false; + } } + function downloadImage() { + if (review?.kind !== 'image-gen') return; + const a = document.createElement('a'); + a.href = review.imageSrc; + const ext = review.contentType === 'image/jpeg' ? 'jpg' : 'png'; + a.download = `${review.slug}-${review.imageType}.${ext}`; + a.click(); + } + + // ── Apply description ───────────────────────────────────────────────────────── + + async function applyDescription() { + if (review?.kind !== 'description' || review.applying) return; + review.applying = true; + review.applyError = ''; + review.applyDone = false; + + try { + const res = await fetch('/api/admin/text-gen/description/apply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ slug: review.slug, description: review.newDescription }) + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + review.applyError = body.error ?? `Error ${res.status}`; + } else { + review.applyDone = true; + } + } catch { + review.applyError = 'Network error.'; + } finally { + review.applying = false; + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────────── function statusBg(status: string) { if (status === 'done') return 'bg-green-400/10 text-green-400'; if (status === 'running') return 'bg-(--color-brand)/10 text-(--color-brand)'; @@ -185,6 +363,8 @@ function kindLabel(kind: string) { const labels: Record = { 'chapter-names': 'Chapter Names', + 'image-gen': 'Image Gen', + 'description': 'Description', 'batch-covers': 'Batch Covers', 'chapter-covers': 'Chapter Covers', 'refresh-metadata': 'Refresh Metadata' @@ -216,6 +396,14 @@ if (!job.items_total) return null; return Math.round((job.items_done / job.items_total) * 100); } + + function fmtBytes(b: number) { + if (b < 1024) return `${b} B`; + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`; + return `${(b / 1024 / 1024).toFixed(2)} MB`; + } + + const REVIEWABLE_KINDS = new Set(['chapter-names', 'image-gen', 'description']);
@@ -390,7 +578,7 @@ {cancellingId === job.id ? 'Cancelling…' : 'Cancel'} {/if} - {#if job.kind === 'chapter-names' && job.status === 'done'} + {#if REVIEWABLE_KINDS.has(job.kind) && job.status === 'done'}
- + {#if review}
- -
- -
-
-

Review Chapter Names

-

- {review.slug} - {#if review.pattern} - · pattern: {review.pattern} - {/if} -

-
- -
+ +
- -
- {#if review.loading} -
- Loading results… + + {#if review.kind === 'chapter-names'} + +
+
+

Review Chapter Names

+

+ {review.slug} + {#if review.pattern} + · pattern: {review.pattern} + {/if} +

- {:else if review.error} -
-

{review.error}

-
- {:else if review.titles.length === 0} -
-

No results found in this job's payload.

-
- {:else} - - - - - - - - - - {#each review.titles as title (title.number)} - - - - + + + + +
+ {#if review.loading} +
Loading results…
+ {:else if review.error} +

{review.error}

+ {:else if review.titles.length === 0} +

No results found in this job's payload.

+ {:else} +
#Old TitleNew Title (editable)
{title.number} - {title.old_title || '—'} - - -
+ + + + + - {/each} - -
#Old TitleNew Title (editable)
- {/if} -
- - - {#if !review.loading && !review.error && review.titles.length > 0} -
-
- {review.titles.length} chapters -
-
- {#if review.applyError} -

{review.applyError}

- {/if} - {#if review.applyDone} -

Applied successfully.

- {/if} - - -
+ + + {#each review.titles as title (title.number)} + + {title.number} + + {title.old_title || '—'} + + + + + + {/each} + + + {/if}
+ + + {#if !review.loading && !review.error && review.titles.length > 0} +
+
{review.titles.length} chapters
+
+ {#if review.applyError}

{review.applyError}

{/if} + {#if review.applyDone}

Applied successfully.

{/if} + + +
+
+ {/if} + + + {:else if review.kind === 'image-gen'} + +
+
+

Review Generated Image

+

+ {review.slug} + {#if review.imageType} · {review.imageType}{/if} +

+
+ +
+ + +
+ {#if review.loading} +
Loading image…
+ {:else if review.error} +

{review.error}

+ {:else if review.imageSrc} +
+ +
+ Generated +
+ +
+ {#if review.prompt} +
+

Prompt

+

{review.prompt}

+
+ {/if} + {#if review.bytes > 0} +
+

Size

+

{fmtBytes(review.bytes)}

+
+ {/if} + {#if review.savedUrl} +

+ Saved as cover → + {review.savedUrl} +

+ {/if} + {#if review.saveError} +

{review.saveError}

+ {/if} +
+
+ {/if} +
+ + + {#if !review.loading && !review.error && review.imageSrc} +
+ +
+ + {#if review.imageType === 'cover' && !review.savedUrl} + + {:else if review.savedUrl} + Saved ✓ + {/if} +
+
+ {/if} + + + {:else if review.kind === 'description'} + +
+
+

Review Description

+

+ {review.slug} +

+
+ +
+ + +
+ {#if review.loading} +
Loading…
+ {:else if review.error} +

{review.error}

+ {:else} + +
+

Current description

+

+ {review.oldDescription || '—'} +

+
+ + +
+

Proposed description (editable)

+ +
+ + {#if review.instructions} +

+ Instructions: {review.instructions} +

+ {/if} + {/if} +
+ + + {#if !review.loading && !review.error} +
+ +
+ {#if review.applyError}

{review.applyError}

{/if} + {#if review.applyDone}

Applied successfully.

{/if} + +
+
+ {/if} {/if}
{/if} diff --git a/ui/src/routes/admin/image-gen/+page.svelte b/ui/src/routes/admin/image-gen/+page.svelte index ed73329..ea40432 100644 --- a/ui/src/routes/admin/image-gen/+page.svelte +++ b/ui/src/routes/admin/image-gen/+page.svelte @@ -1,5 +1,6 @@ @@ -830,9 +718,9 @@ - Generating… {fmtElapsed(elapsedMs)} + Queuing… {:else} - Generate + Generate (async) {/if} @@ -841,116 +729,37 @@ {/if}
- +
- {#if result} -
- - +
+

How it works

+
    +
  1. Fill in the form and click Generate (async).
  2. +
  3. The job is queued in the background — no waiting on this page.
  4. +
  5. You'll be taken to AI Jobs to monitor progress.
  6. +
  7. When done, click Review to see the image and approve or discard it.
  8. +
+ + + + + Go to AI Jobs + +
- -
-
-
-

Model

-

- {models.find((m) => m.id === result!.model)?.label ?? result.model} -

-
-
-

Size

-

{fmtBytes(result.bytes)}

-
-
-

Time

-

{fmtElapsed(result.elapsedMs)}

-
-
- - {#if result.saved} -

- Cover saved → - {result.coverUrl} -

- {/if} - - {#if saveSuccess && !result.saved} -

Cover saved successfully.

- {/if} - {#if saveError} -

{saveError}

- {/if} - - -
- - - {#if result.imageType === 'cover'} - - {/if} -
-
-
- {:else if generating} - -
-
- - - - -

Generating… {fmtElapsed(elapsedMs)}

-
-
- {:else} - -
-

Generated image will appear here

-
- {/if} - - - {#if history.length > 0} -
-

Session history

-
- {#each history as h, i} - - {/each} -
-
- {/if} +
+

Tips

+
    +
  • • Use Auto-prompt to generate a prompt from the book's description.
  • +
  • • FLUX models produce high-quality covers but take 60–120 s — the async path prevents timeouts.
  • +
  • • Keep steps ≤ 20 on Cloudflare Workers AI to stay within the ~100 s limit.
  • +
  • • Reference images (img2img) only work with models that show ★ref.
  • +
+