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
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:
@@ -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 (2–5 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user