feat: stream chapter-name generation via SSE batching
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m16s
Release / Gitea Release (push) Successful in 36s

Split chapter-name LLM requests into 100-chapter batches and stream
results back as SSE so large books (e.g. Shadow Slave: 2916 chapters)
never time out or truncate. Frontend shows live batch progress inline
and accumulates proposals as they arrive.
This commit is contained in:
Admin
2026-04-05 00:32:18 +05:00
parent b0d8c02787
commit ad2d1a2603
6 changed files with 660 additions and 178 deletions

View File

@@ -1,6 +1,12 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
export interface BookSummary {
slug: string;
title: string;
}
export interface TextModelInfo {
id: string;
@@ -12,16 +18,25 @@ export interface TextModelInfo {
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
try {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) {
log.warn('admin/text-gen', 'failed to load models', { status: res.status });
return { models: [] as TextModelInfo[] };
}
const data = await res.json();
return { models: (data.models ?? []) as TextModelInfo[] };
} catch (e) {
log.warn('admin/text-gen', 'backend unreachable', { err: String(e) });
return { models: [] as TextModelInfo[] };
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as TextModelInfo[];
})(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
slug: b.slug,
title: b.title
})) as BookSummary[]
};
};

View File

@@ -1,26 +1,82 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { TextModelInfo } from './+page.server';
import type { TextModelInfo, BookSummary } from './+page.server';
let { data }: { data: PageData } = $props();
const models = data.models as TextModelInfo[];
const books = data.books as BookSummary[];
// ── Config persistence ───────────────────────────────────────────────────────
const CONFIG_KEY = 'admin_text_gen_config_v1';
interface SavedConfig {
selectedModel: string;
activeTab: 'chapters' | 'description';
chPattern: string;
dInstructions: string;
}
function loadConfig(): Partial<SavedConfig> {
if (!browser) return {};
try {
const raw = localStorage.getItem(CONFIG_KEY);
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
} catch { return {}; }
}
function saveConfig() {
if (!browser) return;
const cfg: SavedConfig = { selectedModel, activeTab, chPattern, dInstructions };
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
}
const saved = loadConfig();
// ── Shared ────────────────────────────────────────────────────────────────────
type ActiveTab = 'chapters' | 'description';
let activeTab = $state<ActiveTab>('chapters');
let selectedModel = $state(models[0]?.id ?? '');
let activeTab = $state<ActiveTab>(saved.activeTab ?? 'chapters');
let selectedModel = $state(saved.selectedModel ?? (models[0]?.id ?? ''));
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
$effect(() => { void selectedModel; void activeTab; void chPattern; void dInstructions; saveConfig(); });
function fmtCtx(n: number) {
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
return `${n} ctx`;
}
// ── Book autocomplete (shared component logic) ───────────────────────────────
function makeBookAC() {
let inputVal = $state('');
let focused = $state(false);
const suggestions = $derived(
inputVal.trim().length === 0
? []
: books
.filter((b) =>
b.slug.includes(inputVal.toLowerCase()) ||
b.title.toLowerCase().includes(inputVal.toLowerCase())
)
.slice(0, 8)
);
return {
get inputVal() { return inputVal; },
set inputVal(v: string) { inputVal = v; },
get focused() { return focused; },
set focused(v: boolean) { focused = v; },
get suggestions() { return suggestions; }
};
}
// ── Chapter names state ───────────────────────────────────────────────────────
let chAC = makeBookAC();
let chSlug = $state('');
let chPattern = $state('Chapter {n}: {scene}');
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
let chGenerating = $state(false);
let chError = $state('');
@@ -28,13 +84,13 @@
number: number;
old_title: string;
new_title: string;
// editable copy
edited: string;
}
let chProposals = $state<ProposedChapter[]>([]);
let chRawResponse = $state('');
let chUsedModel = $state('');
let chShowRaw = $state(false);
let chBatchProgress = $state('');
let chBatchWarnings = $state<string[]>([]);
let chApplying = $state(false);
let chApplyError = $state('');
@@ -43,11 +99,24 @@
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
let chCanApply = $derived(chProposals.length > 0 && !chApplying);
function selectChBook(b: BookSummary) {
chSlug = b.slug;
chAC.inputVal = b.slug;
chAC.focused = false;
}
function onChSlugInput() {
chSlug = chAC.inputVal;
}
async function generateChapterNames() {
if (!chCanGenerate) return;
chGenerating = true;
chError = '';
chProposals = [];
chUsedModel = '';
chBatchProgress = '';
chBatchWarnings = [];
chApplySuccess = false;
chApplyError = '';
@@ -61,20 +130,77 @@
model: selectedModel
})
});
const body = await res.json().catch(() => ({}));
// Non-SSE error response (e.g. 400/404/502 before streaming started).
if (!res.ok) {
const body = await res.json().catch(() => ({}));
chError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
chProposals = ((body.chapters ?? []) as { number: number; old_title: string; new_title: string }[]).map(
(p) => ({ ...p, edited: p.new_title })
);
chRawResponse = body.raw_response ?? '';
chUsedModel = body.model ?? '';
// If backend returned chapters:[] but we have a raw response, the model
// output was unparseable (likely truncated). Treat it as an error.
if (chProposals.length === 0 && chRawResponse.trim().length > 0) {
chError = 'Model response could not be parsed (output may be truncated). Raw response shown below.';
// Stream SSE events line by line.
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
outer: while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process all complete SSE messages in the buffer.
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // keep incomplete last line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
let evt: {
batch?: number;
total_batches?: number;
chapters_done?: number;
total_chapters?: number;
model?: string;
chapters?: { number: number; old_title: string; new_title: string }[];
error?: string;
done?: boolean;
};
try {
evt = JSON.parse(payload);
} catch {
continue;
}
if (evt.done) {
chBatchProgress = `Done — ${evt.total_chapters ?? chProposals.length} chapters`;
chGenerating = false;
break outer;
}
if (evt.model) chUsedModel = evt.model;
if (evt.error) {
chBatchWarnings = [
...chBatchWarnings,
`Batch ${evt.batch}/${evt.total_batches} failed: ${evt.error}`
];
} else if (evt.chapters) {
const incoming = (evt.chapters as { number: number; old_title: string; new_title: string }[]).map(
(p) => ({ ...p, edited: p.new_title })
);
chProposals = [...chProposals, ...incoming];
}
if (evt.batch != null && evt.total_batches != null) {
chBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chProposals.length}/${evt.total_chapters ?? '?'} chapters`;
}
}
}
if (chProposals.length === 0 && chBatchWarnings.length === 0) {
chError = 'No proposals returned. The model may have failed to parse the chapters.';
}
} catch {
chError = 'Network error.';
@@ -112,8 +238,9 @@
}
// ── Description state ─────────────────────────────────────────────────────────
let dAC = makeBookAC();
let dSlug = $state('');
let dInstructions = $state('');
let dInstructions = $state(saved.dInstructions ?? '');
let dGenerating = $state(false);
let dError = $state('');
@@ -128,6 +255,16 @@
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
let dCanApply = $derived(dNewDesc.trim().length > 0 && !dApplying);
function selectDBook(b: BookSummary) {
dSlug = b.slug;
dAC.inputVal = b.slug;
dAC.focused = false;
}
function onDSlugInput() {
dSlug = dAC.inputVal;
}
async function generateDescription() {
if (!dCanGenerate) return;
dGenerating = true;
@@ -247,13 +384,37 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
Book slug
</label>
<input
id="ch-slug"
type="text"
bind:value={chSlug}
placeholder="e.g. shadow-slave"
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 class="relative">
<input
id="ch-slug"
type="text"
bind:value={chAC.inputVal}
oninput={onChSlugInput}
onfocus={() => (chAC.focused = true)}
onblur={() => setTimeout(() => { chAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
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)"
/>
{#if chAC.focused && chAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each chAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={chSlug === b.slug}
onmousedown={() => selectChBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="space-y-1">
@@ -292,9 +453,6 @@
{#if chError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
{#if chRawResponse}
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-48 text-(--color-muted) whitespace-pre-wrap break-words">{chRawResponse}</pre>
{/if}
{/if}
</div>
@@ -309,16 +467,25 @@
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
{/if}
</p>
<button
onclick={() => (chShowRaw = !chShowRaw)}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
{chShowRaw ? 'Hide raw' : 'Show raw'}
</button>
{#if chBatchProgress}
<span class="text-xs text-(--color-muted) flex items-center gap-1.5">
{#if chGenerating}
<svg class="w-3 h-3 animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
{/if}
{chBatchProgress}
</span>
{/if}
</div>
{#if chShowRaw}
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-40 text-(--color-muted)">{chRawResponse}</pre>
{#if chBatchWarnings.length > 0}
<div class="space-y-1">
{#each chBatchWarnings as w}
<p class="text-xs text-amber-400 bg-amber-400/10 rounded-lg px-3 py-2">{w}</p>
{/each}
</div>
{/if}
<div class="max-h-[28rem] overflow-y-auto space-y-2 pr-1">
@@ -349,7 +516,7 @@
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
</button>
<button
onclick={() => { chProposals = []; chRawResponse = ''; chApplySuccess = false; }}
onclick={() => { chProposals = []; chBatchProgress = ''; chBatchWarnings = []; chApplySuccess = false; }}
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
hover:text-(--color-text) transition-colors border border-(--color-border)"
>
@@ -373,7 +540,9 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<p class="text-sm text-(--color-muted)">Generating chapter titles…</p>
<p class="text-sm text-(--color-muted)">
{chBatchProgress || 'Generating chapter titles…'}
</p>
</div>
</div>
{:else}
@@ -394,13 +563,37 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
Book slug
</label>
<input
id="d-slug"
type="text"
bind:value={dSlug}
placeholder="e.g. shadow-slave"
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 class="relative">
<input
id="d-slug"
type="text"
bind:value={dAC.inputVal}
oninput={onDSlugInput}
onfocus={() => (dAC.focused = true)}
onblur={() => setTimeout(() => { dAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
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)"
/>
{#if dAC.focused && dAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each dAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={dSlug === b.slug}
onmousedown={() => selectDBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="space-y-1">