feat: async chapter-names AI generation with review & apply
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 1m38s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 3m15s
Release / Docker / runner (push) Successful in 4m34s
Release / Upload source maps (push) Successful in 1m33s
Release / Docker / ui (push) Successful in 3m39s
Release / Gitea Release (push) Successful in 40s

- Add POST /api/admin/text-gen/chapter-names/async backend endpoint: fire-and-forget,
  returns job_id immediately (HTTP 202), runs batch generation in background goroutine,
  persists proposed titles in ai_job payload when done
- Register new route in server.go alongside existing SSE endpoint (backward compat)
- Add SvelteKit proxy at /api/admin/text-gen/chapter-names/async
- Add SvelteKit proxy for GET /api/admin/ai-jobs/[id] (job detail with payload)
- Add Review button on ai-jobs page for done chapter-names jobs; inline panel shows
  editable title table (old to new) with Apply All button that POSTs to chapter-names/apply
This commit is contained in:
Admin
2026-04-05 22:53:47 +05:00
parent 1abb4cd714
commit 45a0190d75
5 changed files with 479 additions and 0 deletions

View File

@@ -371,6 +371,215 @@ func parseChapterTitlesJSON(raw string) []rawChapterTitle {
return out
}
// handleAdminTextGenChapterNamesAsync handles POST /api/admin/text-gen/chapter-names/async.
//
// Fire-and-forget variant: validates inputs, creates an ai_job record, spawns a
// background goroutine, and returns HTTP 202 with {job_id} immediately. The
// goroutine runs all batches, stores the proposed titles in the job payload, and
// marks the job done/failed/cancelled when finished.
//
// The client can poll GET /api/admin/ai-jobs/{id} for progress, then call
// POST /api/admin/text-gen/chapter-names/apply once the job is "done".
func (s *Server) handleAdminTextGenChapterNamesAsync(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
}
var req textGenChapterNamesRequest
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 strings.TrimSpace(req.Pattern) == "" {
jsonError(w, http.StatusBadRequest, "pattern is required")
return
}
// Load existing chapter list (use request context — just for validation).
allChapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
return
}
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
}
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
// Index existing titles for old/new diff.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
batches := chunkChapters(chapters, chapterNamesBatchSize)
totalBatches := len(batches)
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
jobPayload := fmt.Sprintf(`{"pattern":%q}`, req.Pattern)
jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "chapter-names",
Slug: req.Slug,
Status: domain.TaskStatusPending,
FromItem: req.FromChapter,
ToItem: req.ToChapter,
ItemsTotal: len(chapters),
Model: string(model),
Payload: jobPayload,
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.Log.Info("admin: text-gen chapter-names async started",
"job_id", jobID, "slug", req.Slug,
"chapters", len(chapters), "batches", totalBatches, "model", model)
// Mark running before returning so the UI sees it immediately.
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(domain.TaskStatusRunning),
})
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
`The user provides a list of chapter numbers with their current titles, ` +
`and a naming pattern template. ` +
`Your job: produce one new title for every chapter, following the pattern exactly. ` +
`Pattern placeholders: {n} = the chapter number (integer), {scene} = a very short (25 word) scene hint derived from the existing title. ` +
`RULES: ` +
`1. Do NOT include the chapter number inside the title text — the {n} placeholder is already in the pattern. ` +
`2. Do NOT include any prefix like "Chapter X -" or "Chapter X:" inside the title field itself. ` +
`3. The "title" field in your JSON must be the fully-rendered string (e.g. if pattern is "Chapter {n}: {scene}", output "Chapter 3: The Bet"). ` +
`4. Respond ONLY with a raw JSON array — no prose, no markdown fences, no explanation. ` +
`5. Each element: {"number": <int>, "title": <string>}. ` +
`6. Output every chapter in the input list, in order. Do not skip any.`
// Capture all locals needed in the goroutine.
store := s.deps.AIJobStore
textGen := s.deps.TextGen
logger := s.deps.Log
capturedModel := model
capturedMaxTokens := maxTokens
capturedPattern := req.Pattern
capturedSlug := req.Slug
go func() {
defer deregisterCancelJob(jobID)
defer jobCancel()
var allResults []proposedChapterTitle
chaptersDone := 0
for i, batch := range batches {
if jobCtx.Err() != nil {
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusCancelled),
"finished": time.Now().Format(time.RFC3339),
})
return
}
var chapterListSB strings.Builder
for _, ch := range batch {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
}
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", capturedPattern, chapterListSB.String())
raw, 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 chapter-names async batch failed",
"job_id", jobID, "batch", i+1, "err", genErr)
// Continue — skip errored batch rather than aborting.
continue
}
proposed := parseChapterTitlesJSON(raw)
for _, p := range proposed {
allResults = append(allResults, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
})
}
chaptersDone += len(batch)
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"items_done": chaptersDone,
})
}
// Persist results into payload so the UI can load them for review.
resultsJSON, _ := json.Marshal(allResults)
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
capturedPattern, capturedSlug, string(resultsJSON))
status := domain.TaskStatusDone
if jobCtx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),
"payload": finalPayload,
})
logger.Info("admin: text-gen chapter-names async done",
"job_id", jobID, "slug", capturedSlug,
"results", len(allResults), "status", string(status))
}()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}
// ── Apply chapter names ───────────────────────────────────────────────────────
// applyChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names/apply.

View File

@@ -206,6 +206,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Admin text generation endpoints (chapter names + book description)
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
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/apply", s.handleAdminTextGenApplyDescription)