feat: AI job tracking, range support, auto-prompt, and resume
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 55s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 3m21s
Release / Docker / runner (push) Successful in 2m41s
Release / Upload source maps (push) Successful in 3m44s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m22s

- New `ai_jobs` PocketBase collection tracks all long-running AI tasks
  (batch-covers, chapter-names) with status, progress, and cancellation
- `handlers_aijobs.go`: GET/cancel endpoints for ai_jobs; centralised
  cancel registry (moved from handlers_catalogue)
- Batch-covers and chapter-names SSE handlers now create/resume ai_job
  records, support from_item/to_item ranges, and resume from items_done
  on restart via job_id
- New `POST /api/admin/image-gen/auto-prompt`: generates an image prompt
  from book description (cover) or chapter title (chapter) via LLM
- image-gen page: "Auto-prompt" button calls auto-prompt API when a slug
  is selected; falls back gracefully if TextGen not configured
- text-gen chapter-names: from/to chapter range inputs + job ID display
- catalogue-tools batch-covers: from/to item range + resume job ID input
- pb-init-v3.sh: adds ai_jobs collection (idempotent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-05 15:40:52 +05:00
parent 7aad42834f
commit 9c79fd5deb
12 changed files with 710 additions and 44 deletions

View File

@@ -0,0 +1,233 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// ── Cancel registry ────────────────────────────────────────────────────────────
// cancelJobsMu guards cancelJobs.
var cancelJobsMu sync.Mutex
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
// job starts and removed when it finishes or is cancelled.
var cancelJobs = map[string]context.CancelFunc{}
func registerCancelJob(id string, cancel context.CancelFunc) {
cancelJobsMu.Lock()
cancelJobs[id] = cancel
cancelJobsMu.Unlock()
}
func deregisterCancelJob(id string) {
cancelJobsMu.Lock()
delete(cancelJobs, id)
cancelJobsMu.Unlock()
}
// ── AI Job list / get / cancel ─────────────────────────────────────────────────
// handleAdminListAIJobs handles GET /api/admin/ai-jobs.
// Returns all ai_job records sorted by started descending.
func (s *Server) handleAdminListAIJobs(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
jobs, err := s.deps.AIJobStore.ListAIJobs(r.Context())
if err != nil {
s.deps.Log.Error("admin: list ai jobs failed", "err", err)
jsonError(w, http.StatusInternalServerError, "list ai jobs: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"jobs": jobs})
}
// handleAdminGetAIJob handles GET /api/admin/ai-jobs/{id}.
func (s *Server) handleAdminGetAIJob(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
id := r.PathValue("id")
job, ok, err := s.deps.AIJobStore.GetAIJob(r.Context(), id)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", id))
return
}
writeJSON(w, 0, job)
}
// handleAdminCancelAIJob handles POST /api/admin/ai-jobs/{id}/cancel.
// Marks the job as cancelled in PB and cancels the in-memory context if present.
func (s *Server) handleAdminCancelAIJob(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
id := r.PathValue("id")
// Cancel in-memory context if the job is still running in this process.
cancelJobsMu.Lock()
if cancel, ok := cancelJobs[id]; ok {
cancel()
}
cancelJobsMu.Unlock()
// Mark as cancelled in PB.
if err := s.deps.AIJobStore.UpdateAIJob(r.Context(), id, map[string]any{
"status": string(domain.TaskStatusCancelled),
"finished": time.Now().Format(time.RFC3339),
}); err != nil {
s.deps.Log.Error("admin: cancel ai job failed", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, "cancel ai job: "+err.Error())
return
}
s.deps.Log.Info("admin: ai job cancelled", "id", id)
writeJSON(w, 0, map[string]any{"cancelled": true})
}
// ── Auto-prompt ────────────────────────────────────────────────────────────────
// autoPromptRequest is the JSON body for POST /api/admin/image-gen/auto-prompt.
type autoPromptRequest struct {
// Slug is the book slug.
Slug string `json:"slug"`
// Type is "cover" or "chapter".
Type string `json:"type"`
// Chapter number (required when type == "chapter").
Chapter int `json:"chapter"`
// Model is the text-gen model to use. Defaults to DefaultTextModel.
Model string `json:"model"`
}
// autoPromptResponse is returned by POST /api/admin/image-gen/auto-prompt.
type autoPromptResponse struct {
Prompt string `json:"prompt"`
Model string `json:"model"`
}
// handleAdminImageGenAutoPrompt handles POST /api/admin/image-gen/auto-prompt.
//
// Uses the text generation model to create a vivid image generation prompt
// based on the book's description (for covers) or chapter title/content (for chapters).
func (s *Server) handleAdminImageGenAutoPrompt(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req autoPromptRequest
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
}
if req.Type != "cover" && req.Type != "chapter" {
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
return
}
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 := req.Model
if model == "" {
model = string(cfai.DefaultTextModel)
}
var userPrompt string
if req.Type == "cover" {
userPrompt = fmt.Sprintf(
"Book: \"%s\"\nAuthor: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
meta.Author,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
} else {
// For chapter images, use chapter title if available.
chapterTitle := fmt.Sprintf("Chapter %d", req.Chapter)
if req.Chapter > 0 {
chapters, listErr := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if listErr == nil {
for _, ch := range chapters {
if ch.Number == req.Chapter {
chapterTitle = ch.Title
break
}
}
}
}
userPrompt = fmt.Sprintf(
"Book: \"%s\"\nGenres: %s\nChapter: %s\n\nBook description:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
chapterTitle,
meta.Summary,
)
}
systemPrompt := buildAutoPromptSystem(req.Type)
s.deps.Log.Info("admin: image auto-prompt requested",
"slug", req.Slug, "type", req.Type, "chapter", req.Chapter, "model", model)
result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: cfai.TextModel(model),
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: 256,
})
if genErr != nil {
s.deps.Log.Error("admin: auto-prompt failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
writeJSON(w, 0, autoPromptResponse{
Prompt: strings.TrimSpace(result),
Model: model,
})
}
func buildAutoPromptSystem(imageType string) string {
if imageType == "cover" {
return `You are a professional prompt engineer for AI image generation (Stable Diffusion / FLUX models). ` +
`Given a book's title, genres, and description, write a single vivid image generation prompt ` +
`for a book cover. The prompt should describe the visual composition, art style, lighting, ` +
`and mood without mentioning text or typography. ` +
`Format: comma-separated visual descriptors, 3060 words. ` +
`Output ONLY the prompt — no explanation, no quotes, no labels.`
}
return `You are a professional prompt engineer for AI image generation (Stable Diffusion / FLUX models). ` +
`Given a book's title, genres, and a specific chapter title, write a single vivid scene illustration prompt. ` +
`Describe the scene, characters, setting, lighting, and art style. ` +
`Format: comma-separated visual descriptors, 3060 words. ` +
`Output ONLY the prompt — no explanation, no quotes, no labels.`
}

View File

@@ -16,32 +16,12 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// ── Cancel registry ────────────────────────────────────────────────────────
// cancelJobsMu guards cancelJobs.
var cancelJobsMu sync.Mutex
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
// job starts and removed when it finishes or is cancelled.
var cancelJobs = map[string]context.CancelFunc{}
func registerCancelJob(id string, cancel context.CancelFunc) {
cancelJobsMu.Lock()
cancelJobs[id] = cancel
cancelJobsMu.Unlock()
}
func deregisterCancelJob(id string) {
cancelJobsMu.Lock()
delete(cancelJobs, id)
cancelJobsMu.Unlock()
}
// ── Tagline ───────────────────────────────────────────────────────────────
@@ -452,8 +432,9 @@ type batchCoverEvent struct {
// Streams SSE events as it generates covers for every book that has no cover
// stored in MinIO. Each event carries progress info. The final event has Finish=true.
//
// The job can be cancelled by calling POST /api/admin/catalogue/batch-covers/cancel
// with body {"job_id":"..."}.
// Supports from_item/to_item to process a sub-range of the catalogue (0-based indices).
// Supports job_id to resume a previously interrupted job.
// The job can be cancelled by calling POST /api/admin/ai-jobs/{id}/cancel.
func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil || s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured")
@@ -469,22 +450,34 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
NumSteps int `json:"num_steps"`
Width int `json:"width"`
Height int `json:"height"`
FromItem int `json:"from_item"`
ToItem int `json:"to_item"`
JobID string `json:"job_id"`
}
// Body is optional — defaults used if absent.
json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck
books, err := s.deps.BookReader.ListBooks(r.Context())
allBooks, err := s.deps.BookReader.ListBooks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "list books: "+err.Error())
return
}
// Generate a unique job ID.
jobID := randomHex(8)
ctx, cancel := context.WithCancel(r.Context())
registerCancelJob(jobID, cancel)
defer deregisterCancelJob(jobID)
defer cancel()
// Apply range filter.
books := allBooks
if reqBody.FromItem > 0 || reqBody.ToItem > 0 {
from := reqBody.FromItem
to := reqBody.ToItem
if to == 0 || to >= len(allBooks) {
to = len(allBooks) - 1
}
if from < 0 {
from = 0
}
if from <= to && from < len(allBooks) {
books = allBooks[from : to+1]
}
}
// SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
@@ -503,19 +496,75 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
total := len(books)
done := 0
// Send initial event with jobID so frontend can store it for cancellation.
sseWrite(batchCoverEvent{JobID: jobID, Done: 0, Total: total})
// Create or resume PB ai_job and register cancel context.
var pbJobID string
resumeFrom := 0
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
for _, book := range books {
if s.deps.AIJobStore != nil {
if reqBody.JobID != "" {
if existing, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), reqBody.JobID); ok {
pbJobID = reqBody.JobID
resumeFrom = existing.ItemsDone
done = resumeFrom
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
"status": string(domain.TaskStatusRunning),
"items_total": total,
})
}
}
if pbJobID == "" {
id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "batch-covers",
Status: domain.TaskStatusRunning,
FromItem: reqBody.FromItem,
ToItem: reqBody.ToItem,
ItemsTotal: total,
Started: time.Now(),
})
if createErr == nil {
pbJobID = id
}
}
if pbJobID != "" {
registerCancelJob(pbJobID, cancel)
defer deregisterCancelJob(pbJobID)
}
}
// Use pbJobID as the SSE job_id when available, else a random hex fallback.
sseJobID := pbJobID
if sseJobID == "" {
sseJobID = randomHex(8)
ctx2, cancel2 := context.WithCancel(r.Context())
registerCancelJob(sseJobID, cancel2)
defer deregisterCancelJob(sseJobID)
defer cancel2()
cancel() // replace ctx with ctx2
ctx = ctx2
}
// Send initial event with jobID so frontend can store it for cancellation.
sseWrite(batchCoverEvent{JobID: sseJobID, Done: done, Total: total})
for i, book := range books {
if ctx.Err() != nil {
break
}
// Skip already-processed items when resuming.
if i < resumeFrom {
continue
}
// Check if cover already exists.
hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug)
if hasCover {
done++
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true})
if pbJobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
}
continue
}
@@ -547,6 +596,21 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
done++
s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug)
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug})
if pbJobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
}
}
if pbJobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if ctx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
"status": string(status),
"items_done": done,
"finished": time.Now().Format(time.RFC3339),
})
}
sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true})

View File

@@ -1,10 +1,12 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
@@ -38,6 +40,13 @@ type textGenChapterNamesRequest struct {
Model string `json:"model"`
// MaxTokens limits response length (0 = model default).
MaxTokens int `json:"max_tokens"`
// FromChapter is the first chapter to process (1-based). 0 = start from chapter 1.
FromChapter int `json:"from_chapter"`
// ToChapter is the last chapter to process (inclusive). 0 = process all.
ToChapter int `json:"to_chapter"`
// JobID is an optional existing ai_job ID for resuming a previous run.
// If set, the handler resumes from items_done instead of starting from scratch.
JobID string `json:"job_id"`
}
// proposedChapterTitle is a single chapter with its AI-proposed title.
@@ -51,6 +60,8 @@ type proposedChapterTitle struct {
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
type chapterNamesBatchEvent struct {
// JobID is the PB ai_job ID for this run (emitted on the first event only).
JobID string `json:"job_id,omitempty"`
// Batch is the 1-based batch index.
Batch int `json:"batch"`
// TotalBatches is the total number of batches.
@@ -99,16 +110,36 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
// Load existing chapter list.
chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
allChapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
return
}
if len(chapters) == 0 {
if len(allChapters) == 0 {
jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
return
}
// Apply chapter range filter.
chapters := allChapters
if req.FromChapter > 0 || req.ToChapter > 0 {
filtered := chapters[:0]
for _, ch := range allChapters {
if req.FromChapter > 0 && ch.Number < req.FromChapter {
continue
}
if req.ToChapter > 0 && ch.Number > req.ToChapter {
break
}
filtered = append(filtered, ch)
}
chapters = filtered
}
if len(chapters) == 0 {
jsonError(w, http.StatusBadRequest, "no chapters in the specified range")
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
@@ -160,10 +191,58 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
}
chaptersDone := 0
// Create or resume an ai_job record for tracking.
var jobID string
resumeFrom := 0
jobCtx := r.Context()
var jobCancel context.CancelFunc
if s.deps.AIJobStore != nil {
if req.JobID != "" {
if existingJob, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), req.JobID); ok {
jobID = req.JobID
resumeFrom = existingJob.ItemsDone
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(domain.TaskStatusRunning),
"items_total": len(chapters),
})
}
}
if jobID == "" {
jobPayload := fmt.Sprintf(`{"pattern":%q}`, req.Pattern)
id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "chapter-names",
Slug: req.Slug,
Status: domain.TaskStatusRunning,
FromItem: req.FromChapter,
ToItem: req.ToChapter,
ItemsTotal: len(chapters),
Model: string(model),
Payload: jobPayload,
Started: time.Now(),
})
if createErr == nil {
jobID = id
}
}
if jobID != "" {
jobCtx, jobCancel = context.WithCancel(r.Context())
registerCancelJob(jobID, jobCancel)
defer deregisterCancelJob(jobID)
defer jobCancel()
}
}
chaptersDone := resumeFrom
firstEvent := true
for i, batch := range batches {
if r.Context().Err() != nil {
return // client disconnected
if jobCtx.Err() != nil {
return // client disconnected or cancelled
}
// Skip batches already processed in a previous run.
batchEnd := (i + 1) * chapterNamesBatchSize
if batchEnd <= resumeFrom {
continue
}
var chapterListSB strings.Builder
@@ -172,7 +251,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
raw, genErr := s.deps.TextGen.Generate(jobCtx, cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
@@ -183,14 +262,19 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
if genErr != nil {
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
"batch", i+1, "err", genErr)
sseWrite(chapterNamesBatchEvent{
evt := chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Error: genErr.Error(),
})
}
if firstEvent {
evt.JobID = jobID
firstEvent = false
}
sseWrite(evt)
continue
}
@@ -205,13 +289,37 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
chaptersDone += len(batch)
sseWrite(chapterNamesBatchEvent{
if jobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"items_done": chaptersDone,
})
}
evt := chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Chapters: result,
}
if firstEvent {
evt.JobID = jobID
firstEvent = false
}
sseWrite(evt)
}
// Mark job as done in PB.
if jobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if jobCtx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),
})
}

View File

@@ -82,6 +82,9 @@ type Dependencies struct {
// BookWriter writes book metadata and chapter refs to PocketBase.
// Used by admin text-gen apply endpoints.
BookWriter bookstore.BookWriter
// AIJobStore tracks long-running AI generation jobs in PocketBase.
// If nil, job persistence is disabled (jobs still run but are not recorded).
AIJobStore bookstore.AIJobStore
// Log is the structured logger.
Log *slog.Logger
}
@@ -214,6 +217,14 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
// Admin AI job tracking endpoints
mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
// Auto-prompt generation from book/chapter content
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)