feat: AI job tracking, range support, auto-prompt, and resume
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 55s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 3m21s
Release / Docker / runner (push) Successful in 2m41s
Release / Upload source maps (push) Successful in 3m44s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m22s

- New `ai_jobs` PocketBase collection tracks all long-running AI tasks
  (batch-covers, chapter-names) with status, progress, and cancellation
- `handlers_aijobs.go`: GET/cancel endpoints for ai_jobs; centralised
  cancel registry (moved from handlers_catalogue)
- Batch-covers and chapter-names SSE handlers now create/resume ai_job
  records, support from_item/to_item ranges, and resume from items_done
  on restart via job_id
- New `POST /api/admin/image-gen/auto-prompt`: generates an image prompt
  from book description (cover) or chapter title (chapter) via LLM
- image-gen page: "Auto-prompt" button calls auto-prompt API when a slug
  is selected; falls back gracefully if TextGen not configured
- text-gen chapter-names: from/to chapter range inputs + job ID display
- catalogue-tools batch-covers: from/to item range + resume job ID input
- pb-init-v3.sh: adds ai_jobs collection (idempotent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-05 15:40:52 +05:00
parent 7aad42834f
commit 9c79fd5deb
12 changed files with 710 additions and 44 deletions

View File

@@ -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 @@
<input id="height" type="number" bind:value={height} min="0" step="64"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="from-item">From <span class="font-normal">(0=start)</span></label>
<input id="from-item" type="number" bind:value={fromItem} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="to-item">To <span class="font-normal">(0=end)</span></label>
<input id="to-item" type="number" bind:value={toItem} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="col-span-2 sm:col-span-4 space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="resume-job">Resume job ID <span class="font-normal">(leave blank to start fresh)</span></label>
<input id="resume-job" type="text" bind:value={resumeJobID} placeholder="optional PB job ID"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
</div>
<!-- Controls -->

View File

@@ -193,6 +193,33 @@
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
}
// ── Auto-prompt ──────────────────────────────────────────────────────────────
let autoPrompting = $state(false);
let autoPromptError = $state('');
async function autoGeneratePrompt() {
if (!slug.trim() || autoPrompting) return;
autoPrompting = true;
autoPromptError = '';
try {
const res = await fetch('/api/admin/image-gen/auto-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: slug.trim(), type: imageType, chapter: imageType === 'chapter' ? chapter : 0 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
autoPromptError = body.error ?? `Error ${res.status}`;
return;
}
prompt = body.prompt ?? '';
} catch {
autoPromptError = 'Network error.';
} finally {
autoPrompting = false;
}
}
// ── Style presets ────────────────────────────────────────────────────────────
const PRESETS_KEY = 'admin_image_gen_presets_v1';
@@ -583,8 +610,14 @@
Prompt
</label>
<div class="flex flex-wrap items-center gap-3">
{#if slug.trim()}
<button onclick={autoGeneratePrompt} disabled={autoPrompting}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors disabled:opacity-50">
{autoPrompting ? 'Generating…' : 'Auto-prompt'}
</button>
{/if}
{#if selectedBook?.summary}
<button onclick={injectDescription} class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">
<button onclick={injectDescription} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
Inject description
</button>
{/if}
@@ -617,6 +650,9 @@
placeholder="Describe the image to generate…"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand) resize-y"
></textarea>
{#if autoPromptError}
<p class="text-xs text-(--color-danger)">{autoPromptError}</p>
{/if}
<div class="flex gap-2">
<input type="text" bind:value={newPresetName} placeholder="Preset name…"

View File

@@ -80,6 +80,9 @@
let chAC = makeBookAC();
let chSlug = $state('');
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
let chFromChapter = $state(0);
let chToChapter = $state(0);
let chJobID = $state('');
let chGenerating = $state(false);
let chError = $state('');
@@ -122,6 +125,7 @@
chBatchWarnings = [];
chApplySuccess = false;
chApplyError = '';
chJobID = '';
try {
const res = await fetch('/api/admin/text-gen/chapter-names', {
@@ -130,7 +134,9 @@
body: JSON.stringify({
slug: chSlug.trim(),
pattern: chPattern.trim(),
model: selectedModel
model: selectedModel,
from_chapter: chFromChapter || undefined,
to_chapter: chToChapter || undefined
})
});
@@ -161,6 +167,7 @@
if (!payload) continue;
let evt: {
job_id?: string;
batch?: number;
total_batches?: number;
chapters_done?: number;
@@ -176,6 +183,8 @@
continue;
}
if (evt.job_id) chJobID = evt.job_id;
if (evt.done) {
chBatchProgress = `Done — ${evt.total_chapters ?? chProposals.length} chapters`;
chGenerating = false;
@@ -579,6 +588,27 @@
</p>
</div>
<!-- Range (optional) -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-from">
From chapter <span class="font-normal">(0=all)</span>
</label>
<input id="ch-from" type="number" bind:value={chFromChapter} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-to">
To chapter <span class="font-normal">(0=all)</span>
</label>
<input id="ch-to" type="number" bind:value={chToChapter} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
</div>
{#if chJobID}
<p class="text-xs text-(--color-muted)">Job: <span class="font-mono">{chJobID}</span></p>
{/if}
<button
onclick={generateChapterNames}
disabled={!chCanGenerate}