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