diff --git a/backend/internal/backend/handlers_textgen.go b/backend/internal/backend/handlers_textgen.go index f23c52f..313d552 100644 --- a/backend/internal/backend/handlers_textgen.go +++ b/backend/internal/backend/handlers_textgen.go @@ -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": , "title": }. ` + + `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. diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index dc35b7f..0063f4a 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -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) diff --git a/ui/src/routes/admin/ai-jobs/+page.svelte b/ui/src/routes/admin/ai-jobs/+page.svelte index a7920c9..7f4962a 100644 --- a/ui/src/routes/admin/ai-jobs/+page.svelte +++ b/ui/src/routes/admin/ai-jobs/+page.svelte @@ -77,6 +77,92 @@ } } + // ── Review & Apply (chapter-names jobs) ────────────────────────────────────── + + interface ProposedTitle { + number: number; + old_title: string; + new_title: string; + } + + interface ReviewState { + jobId: string; + slug: string; + pattern: string; + titles: ProposedTitle[]; + loading: boolean; + error: string; + applying: boolean; + applyError: string; + applyDone: boolean; + } + + let review = $state(null); + + async function openReview(job: AIJob) { + review = { + jobId: job.id, + slug: job.slug, + pattern: '', + titles: [], + loading: true, + error: '', + applying: false, + applyError: '', + applyDone: false + }; + + 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 + } + + review.pattern = payload.pattern ?? ''; + review.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t })); + review.loading = false; + } catch (e) { + review.loading = false; + review.error = String(e); + } + } + + function closeReview() { + review = null; + } + + async function applyReview() { + if (!review || review.applying) return; + review.applying = true; + review.applyError = ''; + review.applyDone = false; + + const chapters = review.titles.map((t) => ({ number: t.number, title: t.new_title })); + try { + const res = await fetch('/api/admin/text-gen/chapter-names/apply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ slug: review.slug, chapters }) + }); + 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 statusColor(status: string) { if (status === 'done') return 'text-green-400'; @@ -304,6 +390,14 @@ {cancellingId === job.id ? 'Cancelling…' : 'Cancel'} {/if} + {#if job.kind === 'chapter-names' && job.status === 'done'} + + {/if} {#if job.error_message} {/if} + + +{#if review} + + + + +
+ +
+
+

Review Chapter Names

+

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

+
+ +
+ + +
+ {#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} + + + + + + + + + + {#each review.titles as title (title.number)} + + + + + + {/each} + +
#Old TitleNew Title (editable)
{title.number} + {title.old_title || '—'} + + +
+ {/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} +
+{/if} diff --git a/ui/src/routes/api/admin/ai-jobs/[id]/+server.ts b/ui/src/routes/api/admin/ai-jobs/[id]/+server.ts new file mode 100644 index 0000000..aa9920d --- /dev/null +++ b/ui/src/routes/api/admin/ai-jobs/[id]/+server.ts @@ -0,0 +1,31 @@ +/** + * GET /api/admin/ai-jobs/[id] + * + * Admin-only proxy to the Go backend's AI job detail endpoint. + * Returns the full job record including the payload field (which contains + * results for completed chapter-names jobs). + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const GET: RequestHandler = async ({ params, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const { id } = params; + + let res: Response; + try { + res = await backendFetch(`/api/admin/ai-jobs/${id}`, { method: 'GET' }); + } catch (e) { + log.error('admin/ai-jobs/get', 'backend proxy error', { id, err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui/src/routes/api/admin/text-gen/chapter-names/async/+server.ts b/ui/src/routes/api/admin/text-gen/chapter-names/async/+server.ts new file mode 100644 index 0000000..89a7c68 --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/chapter-names/async/+server.ts @@ -0,0 +1,35 @@ +/** + * POST /api/admin/text-gen/chapter-names/async + * + * Fire-and-forget variant: forwards to the Go backend's async endpoint and + * returns {job_id} immediately (HTTP 202). The backend runs generation in the + * background; the client polls GET /api/admin/ai-jobs/{id} for progress and + * then reviews/applies via POST /api/admin/text-gen/chapter-names/apply. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const body = await request.text(); + let res: Response; + try { + res = await backendFetch('/api/admin/text-gen/chapter-names/async', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/chapter-names/async', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +};