Compare commits

...

2 Commits

Author SHA1 Message Date
root
5bc69ad9ce fix(cfai): handle Llama 4 Scout direct JSON array response in Generate()
All checks were successful
Release / Test backend (push) Successful in 6m5s
Release / Test UI (push) Successful in 1m44s
Release / Build and push images (push) Successful in 7m6s
Release / Deploy to prod (push) Successful in 3m47s
Release / Deploy to homelab (push) Successful in 8s
Release / Gitea Release (push) Successful in 44s
Llama 4 Scout returns the model output directly as a JSON array in the
'response' field rather than as a plain string or [{"generated_text":"..."}].
The old fallback parsed it as [{"generated_text":""}] (Go JSON ignores
unknown fields) and silently returned an empty string — causing chapter-names
jobs to finish with results:null in the payload despite items_done matching
items_total.

Fix: only accept the generated_text fallback when the value is non-empty;
otherwise return the raw JSON bytes as a string so callers (parseChapterTitlesJSON
etc.) can extract what they need.
2026-04-24 18:24:25 +05:00
root
c09d9d0ca2 fix: render chapter page when content missing + admin re-scrape toolbar
All checks were successful
Release / Test backend (push) Successful in 6m35s
Release / Test UI (push) Successful in 1m41s
Release / Build and push images (push) Successful in 4m39s
Release / Deploy to prod (push) Successful in 2m20s
Release / Deploy to homelab (push) Successful in 13s
Release / Gitea Release (push) Successful in 27s
2026-04-20 12:03:21 +05:00
4 changed files with 190 additions and 12 deletions

View File

@@ -237,14 +237,18 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
return text, nil
}
// Fall back: array of objects with a "generated_text" field.
// Fall back: array of objects with a "generated_text" field
// (older CF AI models return [{"generated_text":"..."}]).
var arr []struct {
GeneratedText string `json:"generated_text"`
}
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 {
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 && arr[0].GeneratedText != "" {
return arr[0].GeneratedText, nil
}
return "", fmt.Errorf("cfai/text: model %s: unrecognised response shape: %s", req.Model, wrapper.Result.Response)
// Final fallback: model returned the result directly as a JSON value
// (e.g. Llama 4 Scout returns [{"number":1,"title":"..."},...] directly).
// Return the raw JSON bytes as a string so callers can parse it themselves.
return string(wrapper.Result.Response), nil
}
// Models returns all supported text generation model metadata.

View File

@@ -15,5 +15,5 @@ export const GET: RequestHandler = async ({ params }) => {
const task = await getScrapingTask(id).catch(() => null);
if (!task) throw error(404, 'Task not found');
return json({ id: task.id, status: task.status, error_message: task.error_message ?? '' });
return json({ id: task.id, status: task.status, chapters_scraped: task.chapters_scraped ?? 0, error_message: task.error_message ?? '' });
};

View File

@@ -167,17 +167,24 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
// ── Original content path ──────────────────────────────────────────────
let html = '';
let contentMissing = false;
try {
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
if (!res.ok) {
log.error('chapter', 'chapter-markdown returned error', { slug, n, status: res.status });
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
if (res.status === 404) {
// Content file missing from storage — render the page shell with an
// empty body so admins can trigger a re-scrape from the page itself.
contentMissing = true;
} else {
error(502, 'Could not fetch chapter content');
}
} else {
const markdown = await res.text();
html = await marked(markdown);
}
const markdown = await res.text();
html = await marked(markdown);
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
// Don't hard-fail — show empty content with error message
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
error(502, 'Could not fetch chapter content');
}
@@ -202,9 +209,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
return {
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', source_url: book.source_url ?? '' },
chapter: chapterIdx,
html,
contentMissing,
voices,
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
@@ -214,6 +222,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
lang: useTranslation ? lang : '',
translationStatus,
isPro: locals.isPro,
isAdmin: locals.user?.role === 'admin',
chapterImageUrl,
audioReady,
availableVoice

View File

@@ -374,7 +374,9 @@
}
// If the normal path returned no content, fall back to live preview scrape
if (!data.isPreview && !data.html) {
// (but only when contentMissing is false — if the file is genuinely absent
// from storage the preview scrape won't help either and admins can re-queue)
if (!data.isPreview && !data.html && !data.contentMissing) {
fetchingContent = true;
(async () => {
try {
@@ -406,6 +408,62 @@
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
);
// ── Admin chapter tools ────────────────────────────────────────────────────
let adminOpen = $state(false);
let scrapeStatus = $state<'idle' | 'busy' | 'queued' | 'error'>('idle');
let scrapeTaskId = $state('');
let scrapeProgress = $state('');
let pollTimer = 0;
async function scrapeThisChapter() {
if (scrapeStatus === 'busy' || !data.book.source_url) return;
scrapeStatus = 'busy';
scrapeProgress = '';
try {
const res = await fetch('/api/scrape/range', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: data.book.source_url, from: data.chapter.number, to: data.chapter.number })
});
const d = await res.json().catch(() => ({}));
if (res.ok) {
scrapeStatus = 'queued';
scrapeTaskId = d.task_id ?? '';
if (scrapeTaskId) startPollTask(scrapeTaskId);
} else if (res.status === 409) {
scrapeStatus = 'error';
scrapeProgress = 'A scrape job is already running — try again shortly.';
} else {
scrapeStatus = 'error';
scrapeProgress = d.message ?? `Error ${res.status}`;
}
} catch (e) {
scrapeStatus = 'error';
scrapeProgress = String(e);
}
}
function startPollTask(id: string) {
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/scrape/task/${id}`);
if (!res.ok) return;
const d = await res.json() as { status: string; chapters_scraped?: number; error_message?: string };
if (d.status === 'done') {
scrapeProgress = `Done — ${d.chapters_scraped ?? 0} chapter(s) scraped. Reload to see content.`;
scrapeStatus = 'queued';
} else if (d.status === 'failed') {
scrapeProgress = `Failed: ${d.error_message ?? 'unknown error'}`;
scrapeStatus = 'error';
} else {
scrapeProgress = `Status: ${d.status}${d.chapters_scraped ? ` (${d.chapters_scraped} done)` : ''}`;
startPollTask(id); // keep polling
}
} catch { /* ignore */ }
}, 3000) as unknown as number;
}
// Strip scraper artifacts from chapter titles:
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
// - Everything after the first newline (often includes a scraped date)
@@ -717,8 +775,115 @@
{m.reader_fetching_chapter()}
</div>
{:else if !html}
<div class="text-(--color-muted) text-center py-16">
<p>{fetchError || m.reader_audio_error()}</p>
<!-- ── Content unavailable ─────────────────────────────────────────── -->
<div class="mt-8 rounded-xl border border-(--color-border) bg-(--color-surface-2) overflow-hidden">
<!-- Banner — visible to all users -->
<div class="flex items-start gap-4 p-6">
<div class="w-10 h-10 rounded-full bg-(--color-surface-3) flex items-center justify-center shrink-0 mt-0.5">
<svg class="w-5 h-5 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<p class="font-semibold text-(--color-text) mb-1">Chapter content not available</p>
<p class="text-sm text-(--color-muted)">
{#if data.contentMissing}
This chapter exists in the index but its content hasn't been stored yet.
{#if !data.isAdmin}It should appear soon — try refreshing in a few minutes.{/if}
{:else}
{fetchError || 'Could not load this chapter. Please try again.'}
{/if}
</p>
</div>
</div>
<!-- Admin toolbar — only visible to admins -->
{#if data.isAdmin}
<div class="border-t border-(--color-border)">
<button
type="button"
onclick={() => (adminOpen = !adminOpen)}
class="w-full flex items-center gap-2 px-5 py-3 text-xs font-semibold text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)/40 transition-colors text-left"
>
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Admin Tools
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{#if adminOpen}
<div class="px-5 pb-5 flex flex-col gap-4 border-t border-(--color-border)">
<!-- Source URL info -->
{#if data.book.source_url}
<div class="mt-3">
<p class="text-xs text-(--color-muted) mb-1">Source URL</p>
<a href={data.book.source_url} target="_blank" rel="noopener noreferrer"
class="text-xs text-(--color-brand) hover:underline break-all">
{data.book.source_url}
</a>
</div>
{/if}
<!-- Scrape this chapter -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Scrape Chapter {data.chapter.number}</p>
<div class="flex items-center gap-2 flex-wrap">
<button
type="button"
onclick={scrapeThisChapter}
disabled={scrapeStatus === 'busy' || !data.book.source_url}
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
>
{#if scrapeStatus === 'busy'}
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Scraping…
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Re-scrape ch.{data.chapter.number}
{/if}
</button>
{#if !data.book.source_url}
<span class="text-xs text-(--color-muted)">No source URL on this book.</span>
{/if}
</div>
{#if scrapeProgress}
<p class="text-xs {scrapeStatus === 'error' ? 'text-red-400' : 'text-green-400'}">{scrapeProgress}</p>
{:else if scrapeStatus === 'queued'}
<p class="text-xs text-(--color-muted)">Queued — polling for completion…</p>
{/if}
</div>
<!-- Quick links -->
<div class="flex flex-col gap-1.5">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Quick links</p>
<div class="flex flex-wrap gap-2">
<a href="/admin/scrape" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
Scrape dashboard
</a>
<a href="/books/{data.book.slug}" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
Book page
</a>
<a href="https://pb.libnovel.cc/_/" target="_blank" rel="noopener noreferrer"
class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
PocketBase ↗
</a>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->