diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 9f531d2..0d6fec9 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -195,6 +195,7 @@ func run() error { ImageGen: imageGenClient, TextGen: textGenClient, BookWriter: store, + AIJobStore: store, Log: log, }, ) diff --git a/backend/internal/backend/handlers_aijobs.go b/backend/internal/backend/handlers_aijobs.go new file mode 100644 index 0000000..719ff59 --- /dev/null +++ b/backend/internal/backend/handlers_aijobs.go @@ -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, 30–60 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, 30–60 words. ` + + `Output ONLY the prompt — no explanation, no quotes, no labels.` +} diff --git a/backend/internal/backend/handlers_catalogue.go b/backend/internal/backend/handlers_catalogue.go index 4a2c4d3..662276a 100644 --- a/backend/internal/backend/handlers_catalogue.go +++ b/backend/internal/backend/handlers_catalogue.go @@ -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}) diff --git a/backend/internal/backend/handlers_textgen.go b/backend/internal/backend/handlers_textgen.go index c42dcd3..f23c52f 100644 --- a/backend/internal/backend/handlers_textgen.go +++ b/backend/internal/backend/handlers_textgen.go @@ -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), }) } diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index a4c6704..efab799 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -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) diff --git a/backend/internal/bookstore/bookstore.go b/backend/internal/bookstore/bookstore.go index bdcc75e..79a790a 100644 --- a/backend/internal/bookstore/bookstore.go +++ b/backend/internal/bookstore/bookstore.go @@ -158,6 +158,19 @@ type CoverStore interface { CoverExists(ctx context.Context, slug string) bool } +// AIJobStore manages AI generation jobs tracked in PocketBase. +type AIJobStore interface { + // CreateAIJob inserts a new ai_job record with status=running and returns its ID. + CreateAIJob(ctx context.Context, job domain.AIJob) (string, error) + // GetAIJob retrieves a single ai_job by ID. + // Returns (zero, false, nil) when not found. + GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error) + // UpdateAIJob patches an existing ai_job record with the given fields. + UpdateAIJob(ctx context.Context, id string, fields map[string]any) error + // ListAIJobs returns all ai_job records sorted by started descending. + ListAIJobs(ctx context.Context) ([]domain.AIJob, error) +} + // TranslationStore covers machine-translated chapter storage in MinIO. // The runner writes translations; the backend reads them. type TranslationStore interface { diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index f6ede8e..51f9129 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -169,3 +169,30 @@ type TranslationResult struct { ObjectKey string `json:"object_key,omitempty"` ErrorMessage string `json:"error_message,omitempty"` } + +// AIJob represents an AI generation task tracked in PocketBase (ai_jobs collection). +type AIJob struct { + ID string `json:"id"` + // Kind is one of: "chapter-names", "batch-covers", "chapter-covers", "refresh-metadata". + Kind string `json:"kind"` + // Slug is the book slug for per-book jobs; empty for catalogue-wide jobs. + Slug string `json:"slug"` + Status TaskStatus `json:"status"` + // FromItem is the first item to process (chapter number, or 0-based book index). + // 0 = start from the beginning. + FromItem int `json:"from_item"` + // ToItem is the last item to process (inclusive). 0 = process all. + ToItem int `json:"to_item"` + // ItemsDone is the cumulative count of successfully processed items. + ItemsDone int `json:"items_done"` + // ItemsTotal is the total number of items in this job. + ItemsTotal int `json:"items_total"` + Model string `json:"model"` + // Payload is a JSON-encoded string with job-specific parameters + // (e.g. naming pattern for chapter-names, num_steps for batch-covers). + Payload string `json:"payload"` + ErrorMessage string `json:"error_message,omitempty"` + Started time.Time `json:"started,omitempty"` + Finished time.Time `json:"finished,omitempty"` + HeartbeatAt time.Time `json:"heartbeat_at,omitempty"` +} diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index 588071e..d90ed38 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -53,6 +53,7 @@ var _ bookstore.PresignStore = (*Store)(nil) var _ bookstore.ProgressStore = (*Store)(nil) var _ bookstore.CoverStore = (*Store)(nil) var _ bookstore.TranslationStore = (*Store)(nil) +var _ bookstore.AIJobStore = (*Store)(nil) var _ taskqueue.Producer = (*Store)(nil) var _ taskqueue.Consumer = (*Store)(nil) var _ taskqueue.Reader = (*Store)(nil) @@ -1063,3 +1064,107 @@ func (s *Store) GetTranslation(ctx context.Context, key string) (string, error) } return string(data), nil } + +// ── AIJobStore ──────────────────────────────────────────────────────────────── + +func (s *Store) CreateAIJob(ctx context.Context, job domain.AIJob) (string, error) { + payload := map[string]any{ + "kind": job.Kind, + "slug": job.Slug, + "status": string(job.Status), + "from_item": job.FromItem, + "to_item": job.ToItem, + "items_done": job.ItemsDone, + "items_total": job.ItemsTotal, + "model": job.Model, + "payload": job.Payload, + "started": job.Started.Format(time.RFC3339), + } + var out struct { + ID string `json:"id"` + } + if err := s.pb.post(ctx, "/api/collections/ai_jobs/records", payload, &out); err != nil { + return "", fmt.Errorf("CreateAIJob: %w", err) + } + return out.ID, nil +} + +func (s *Store) GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error) { + var raw json.RawMessage + if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), &raw); err != nil { + if strings.Contains(err.Error(), "404") { + return domain.AIJob{}, false, nil + } + return domain.AIJob{}, false, fmt.Errorf("GetAIJob: %w", err) + } + job, err := parseAIJob(raw) + if err != nil { + return domain.AIJob{}, false, err + } + return job, true, nil +} + +func (s *Store) UpdateAIJob(ctx context.Context, id string, fields map[string]any) error { + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), fields) +} + +func (s *Store) ListAIJobs(ctx context.Context) ([]domain.AIJob, error) { + items, err := s.pb.listAll(ctx, "ai_jobs", "", "-started") + if err != nil { + return nil, fmt.Errorf("ListAIJobs: %w", err) + } + out := make([]domain.AIJob, 0, len(items)) + for _, raw := range items { + j, err := parseAIJob(raw) + if err != nil { + continue + } + out = append(out, j) + } + return out, nil +} + +func parseAIJob(raw json.RawMessage) (domain.AIJob, error) { + var r struct { + ID string `json:"id"` + Kind string `json:"kind"` + Slug string `json:"slug"` + Status string `json:"status"` + FromItem int `json:"from_item"` + ToItem int `json:"to_item"` + ItemsDone int `json:"items_done"` + ItemsTotal int `json:"items_total"` + Model string `json:"model"` + Payload string `json:"payload"` + ErrorMessage string `json:"error_message"` + Started string `json:"started"` + Finished string `json:"finished"` + HeartbeatAt string `json:"heartbeat_at"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return domain.AIJob{}, fmt.Errorf("parseAIJob: %w", err) + } + parseT := func(s string) time.Time { + if s == "" { + return time.Time{} + } + t, _ := time.Parse(time.RFC3339, s) + return t + } + return domain.AIJob{ + ID: r.ID, + Kind: r.Kind, + Slug: r.Slug, + Status: domain.TaskStatus(r.Status), + FromItem: r.FromItem, + ToItem: r.ToItem, + ItemsDone: r.ItemsDone, + ItemsTotal: r.ItemsTotal, + Model: r.Model, + Payload: r.Payload, + ErrorMessage: r.ErrorMessage, + Started: parseT(r.Started), + Finished: parseT(r.Finished), + HeartbeatAt: parseT(r.HeartbeatAt), + }, nil +} diff --git a/scripts/pb-init-v3.sh b/scripts/pb-init-v3.sh index 888d233..de5df5d 100755 --- a/scripts/pb-init-v3.sh +++ b/scripts/pb-init-v3.sh @@ -294,6 +294,23 @@ create "translation_jobs" '{ {"name":"heartbeat_at", "type":"date"} ]}' +create "ai_jobs" '{ + "name":"ai_jobs","type":"base","fields":[ + {"name":"kind", "type":"text", "required":true}, + {"name":"slug", "type":"text"}, + {"name":"status", "type":"text", "required":true}, + {"name":"from_item", "type":"number"}, + {"name":"to_item", "type":"number"}, + {"name":"items_done", "type":"number"}, + {"name":"items_total", "type":"number"}, + {"name":"model", "type":"text"}, + {"name":"payload", "type":"text"}, + {"name":"error_message", "type":"text"}, + {"name":"started", "type":"date"}, + {"name":"finished", "type":"date"}, + {"name":"heartbeat_at", "type":"date"} + ]}' + create "discovery_votes" '{ "name":"discovery_votes","type":"base","fields":[ {"name":"session_id","type":"text","required":true}, diff --git a/ui/src/routes/admin/catalogue-tools/+page.svelte b/ui/src/routes/admin/catalogue-tools/+page.svelte index e3b5284..21bc1cc 100644 --- a/ui/src/routes/admin/catalogue-tools/+page.svelte +++ b/ui/src/routes/admin/catalogue-tools/+page.svelte @@ -39,6 +39,9 @@ $effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); }); // ── Batch covers ────────────────────────────────────────────────────────────── + let fromItem = $state(0); + let toItem = $state(0); + let resumeJobID = $state(''); let running = $state(false); let jobID = $state(''); let done = $state(0); @@ -62,6 +65,9 @@ num_steps: numSteps || undefined, width: width || undefined, height: height || undefined, + from_item: fromItem || undefined, + to_item: toItem || undefined, + job_id: resumeJobID.trim() || undefined, }) }); if (!res.ok) { @@ -172,6 +178,21 @@ +