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
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:
@@ -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 -->
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user