Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45a0190d75 |
@@ -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)
|
||||
|
||||
@@ -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<ReviewState | null>(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'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.kind === 'chapter-names' && job.status === 'done'}
|
||||
<button
|
||||
onclick={() => openReview(job)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-green-400/10 text-green-400 hover:bg-green-400/20 transition-colors"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.error_message}
|
||||
<span
|
||||
class="text-xs text-(--color-danger) max-w-[12rem] truncate"
|
||||
@@ -324,3 +418,112 @@
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Review & Apply panel ──────────────────────────────────────────────────── -->
|
||||
{#if review}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||
onclick={closeReview}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[85vh] overflow-hidden flex flex-col rounded-t-2xl bg-(--color-surface) border-t border-(--color-border) shadow-2xl sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:w-[min(900px,95vw)] sm:max-h-[85vh] sm:rounded-xl sm:border sm:shadow-2xl">
|
||||
<!-- Panel header -->
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Review Chapter Names</h2>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||
<span class="font-mono">{review.slug}</span>
|
||||
{#if review.pattern}
|
||||
· pattern: <span class="font-mono">{review.pattern}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={closeReview}
|
||||
class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if review.loading}
|
||||
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">
|
||||
Loading results…
|
||||
</div>
|
||||
{:else if review.error}
|
||||
<div class="px-5 py-8 text-center">
|
||||
<p class="text-(--color-danger) text-sm">{review.error}</p>
|
||||
</div>
|
||||
{:else if review.titles.length === 0}
|
||||
<div class="px-5 py-8 text-center">
|
||||
<p class="text-(--color-muted) text-sm">No results found in this job's payload.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 bg-(--color-surface) border-b border-(--color-border)">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-16">#</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-1/2">Old Title</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">New Title (editable)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)">
|
||||
{#each review.titles as title (title.number)}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
|
||||
<td class="px-4 py-2 text-xs text-(--color-muted) tabular-nums">{title.number}</td>
|
||||
<td class="px-4 py-2 text-xs text-(--color-muted) max-w-0">
|
||||
<span class="block truncate" title={title.old_title}>{title.old_title || '—'}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title.new_title}
|
||||
class="w-full px-2 py-1 rounded bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if !review.loading && !review.error && review.titles.length > 0}
|
||||
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
|
||||
<div class="text-xs text-(--color-muted)">
|
||||
{review.titles.length} chapters
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if review.applyError}
|
||||
<p class="text-xs text-(--color-danger)">{review.applyError}</p>
|
||||
{/if}
|
||||
{#if review.applyDone}
|
||||
<p class="text-xs text-green-400">Applied successfully.</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={closeReview}
|
||||
class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onclick={applyReview}
|
||||
disabled={review.applying || review.applyDone}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{review.applying ? 'Applying…' : review.applyDone ? 'Applied' : 'Apply All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
31
ui/src/routes/api/admin/ai-jobs/[id]/+server.ts
Normal file
31
ui/src/routes/api/admin/ai-jobs/[id]/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user