diff --git a/ui/messages/en.json b/ui/messages/en.json index 26d202c..8ce3fe4 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -403,6 +403,7 @@ "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", + "admin_nav_ai_jobs": "AI Jobs", "admin_nav_feedback": "Feedback", "admin_nav_errors": "Errors", "admin_nav_analytics": "Analytics", diff --git a/ui/messages/fr.json b/ui/messages/fr.json index 3751c7f..78810ec 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -403,6 +403,7 @@ "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", + "admin_nav_ai_jobs": "Tâches IA", "admin_nav_errors": "Erreurs", "admin_nav_analytics": "Analytique", "admin_nav_logs": "Journaux", diff --git a/ui/messages/id.json b/ui/messages/id.json index 9070748..70b71cb 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -403,6 +403,7 @@ "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", + "admin_nav_ai_jobs": "Tugas AI", "admin_nav_errors": "Kesalahan", "admin_nav_analytics": "Analitik", "admin_nav_logs": "Log", diff --git a/ui/messages/pt.json b/ui/messages/pt.json index 64bf8b0..26007d5 100644 --- a/ui/messages/pt.json +++ b/ui/messages/pt.json @@ -403,6 +403,7 @@ "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", + "admin_nav_ai_jobs": "Tarefas de IA", "admin_nav_errors": "Erros", "admin_nav_analytics": "Análise", "admin_nav_logs": "Logs", diff --git a/ui/messages/ru.json b/ui/messages/ru.json index 88ccac7..3e788ed 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -403,6 +403,7 @@ "admin_nav_image_gen": "Image Gen", "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", + "admin_nav_ai_jobs": "Задачи ИИ", "admin_nav_errors": "Ошибки", "admin_nav_analytics": "Аналитика", "admin_nav_logs": "Логи", diff --git a/ui/src/lib/paraglide/messages/_index.js b/ui/src/lib/paraglide/messages/_index.js index 5e3d5f7..11a10d0 100644 --- a/ui/src/lib/paraglide/messages/_index.js +++ b/ui/src/lib/paraglide/messages/_index.js @@ -374,6 +374,7 @@ export * from './admin_nav_changelog.js' export * from './admin_nav_image_gen.js' export * from './admin_nav_text_gen.js' export * from './admin_nav_catalogue_tools.js' +export * from './admin_nav_ai_jobs.js' export * from './admin_nav_feedback.js' export * from './admin_nav_errors.js' export * from './admin_nav_analytics.js' diff --git a/ui/src/lib/paraglide/messages/admin_nav_ai_jobs.js b/ui/src/lib/paraglide/messages/admin_nav_ai_jobs.js new file mode 100644 index 0000000..c7ee273 --- /dev/null +++ b/ui/src/lib/paraglide/messages/admin_nav_ai_jobs.js @@ -0,0 +1,44 @@ +/* eslint-disable */ +import { getLocale, experimentalStaticLocale } from '../runtime.js'; + +/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ + +/** @typedef {{}} Admin_Nav_Ai_JobsInputs */ + +const en_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`AI Jobs`) +}; + +const ru_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Задачи ИИ`) +}; + +const id_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Tugas AI`) +}; + +const pt_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Tarefas de IA`) +}; + +const fr_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Tâches IA`) +}; + +/** +* | output | +* | --- | +* | "AI Jobs" | +* +* @param {Admin_Nav_Ai_JobsInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ +export const admin_nav_ai_jobs = /** @type {((inputs?: Admin_Nav_Ai_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { + const locale = experimentalStaticLocale ?? options.locale ?? getLocale() + if (locale === "en") return en_admin_nav_ai_jobs(inputs) + if (locale === "ru") return ru_admin_nav_ai_jobs(inputs) + if (locale === "id") return id_admin_nav_ai_jobs(inputs) + if (locale === "pt") return pt_admin_nav_ai_jobs(inputs) + return fr_admin_nav_ai_jobs(inputs) +}); \ No newline at end of file diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 1b473ca..f4e1508 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -14,6 +14,23 @@ const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123'; // ─── Types ──────────────────────────────────────────────────────────────────── +export interface AIJob { + id: string; + kind: string; + slug: string; + status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled'; + from_item: number; + to_item: number; + items_done: number; + items_total: number; + model: string; + payload: string; + error_message?: string; + started?: string; + finished?: string; + heartbeat_at?: string; +} + export interface Book { id: string; slug: string; @@ -2162,3 +2179,13 @@ export async function getUserStats( streak }; } + +// ─── AI Jobs ────────────────────────────────────────────────────────────────── + +/** + * List all AI jobs from PocketBase, sorted by started descending. + * No caching — admin views always want fresh data. + */ +export async function listAIJobs(): Promise { + return listAll('ai_jobs', '', '-started'); +} diff --git a/ui/src/lib/server/scraper.ts b/ui/src/lib/server/scraper.ts index f5871a5..f5548ec 100644 --- a/ui/src/lib/server/scraper.ts +++ b/ui/src/lib/server/scraper.ts @@ -12,6 +12,7 @@ import { error } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; +import * as cache from '$lib/server/cache'; export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080'; @@ -33,6 +34,50 @@ export async function backendFetch(path: string, init?: RequestInit): Promise(): Promise { + const key = 'backend:models:image-gen'; + const cached = await cache.get(key); + if (cached) return cached; + try { + const res = await backendFetch('/api/admin/image-gen/models'); + if (!res.ok) return []; + const data = await res.json(); + const models = (data.models ?? []) as T[]; + await cache.set(key, models, MODELS_CACHE_TTL); + return models; + } catch { + return []; + } +} + +/** + * Fetch text-gen model list from the Go backend with a 10-minute cache. + * Returns an empty array on error. + */ +export async function listTextModels(): Promise { + const key = 'backend:models:text-gen'; + const cached = await cache.get(key); + if (cached) return cached; + try { + const res = await backendFetch('/api/admin/text-gen/models'); + if (!res.ok) return []; + const data = await res.json(); + const models = (data.models ?? []) as T[]; + await cache.set(key, models, MODELS_CACHE_TTL); + return models; + } catch { + return []; + } +} + // ─── Response types ─────────────────────────────────────────────────────────── /** diff --git a/ui/src/routes/admin/+layout.svelte b/ui/src/routes/admin/+layout.svelte index e022fd0..a04138f 100644 --- a/ui/src/routes/admin/+layout.svelte +++ b/ui/src/routes/admin/+layout.svelte @@ -9,7 +9,8 @@ { href: '/admin/changelog', label: () => m.admin_nav_changelog() }, { href: '/admin/image-gen', label: () => m.admin_nav_image_gen() }, { href: '/admin/text-gen', label: () => m.admin_nav_text_gen() }, - { href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() } + { href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }, + { href: '/admin/ai-jobs', label: () => m.admin_nav_ai_jobs() } ]; const externalLinks = [ diff --git a/ui/src/routes/admin/ai-jobs/+page.server.ts b/ui/src/routes/admin/ai-jobs/+page.server.ts new file mode 100644 index 0000000..c37bff7 --- /dev/null +++ b/ui/src/routes/admin/ai-jobs/+page.server.ts @@ -0,0 +1,14 @@ +import type { PageServerLoad } from './$types'; +import { listAIJobs, type AIJob } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +export type { AIJob }; + +export const load: PageServerLoad = async () => { + // Parent layout already guards admin role. + const jobs = await listAIJobs().catch((e): AIJob[] => { + log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) }); + return []; + }); + return { jobs }; +}; diff --git a/ui/src/routes/admin/ai-jobs/+page.svelte b/ui/src/routes/admin/ai-jobs/+page.svelte new file mode 100644 index 0000000..a7920c9 --- /dev/null +++ b/ui/src/routes/admin/ai-jobs/+page.svelte @@ -0,0 +1,326 @@ + + +
+ +
+
+

AI Jobs

+

Background AI generation tasks

+
+ +
+ + +
+ {#each [ + { label: 'Total', value: stats.total, color: 'text-(--color-text)' }, + { label: 'Running', value: stats.running, color: 'text-(--color-brand)' }, + { label: 'Pending', value: stats.pending, color: 'text-sky-400' }, + { label: 'Done', value: stats.done, color: 'text-green-400' }, + { label: 'Failed', value: stats.failed, color: 'text-(--color-danger)' } + ] as stat} +
+

{stat.label}

+

{stat.value}

+
+ {/each} +
+ + +
+ +
+ {#each STATUS_OPTIONS as s} + + {/each} +
+
+ + + {#if cancelError} +

{cancelError}

+ {/if} + + + {#if filteredJobs.length === 0} +
+

+ {jobs.length === 0 ? 'No AI jobs found.' : 'No jobs match your filters.'} +

+
+ {:else} +
+ + + + + + + + + + + + + + + {#each filteredJobs as job (job.id)} + + + + + + + + + + + + + + + + + + + + + + + + + + {/each} + +
StatusKindSlugActions
+ + {job.status} + + + {kindLabel(job.kind)} + + {#if job.slug} + + {job.slug} + + {:else} + + {/if} + +
+ {#if job.status === 'pending' || job.status === 'running'} + + {/if} + {#if job.error_message} + + {job.error_message} + + {/if} +
+
+
+

+ Showing {filteredJobs.length} of {jobs.length} jobs +

+ {/if} +
diff --git a/ui/src/routes/admin/catalogue-tools/+page.server.ts b/ui/src/routes/admin/catalogue-tools/+page.server.ts index 4cc1ad0..49915e5 100644 --- a/ui/src/routes/admin/catalogue-tools/+page.server.ts +++ b/ui/src/routes/admin/catalogue-tools/+page.server.ts @@ -1,5 +1,5 @@ import type { PageServerLoad } from './$types'; -import { backendFetch } from '$lib/server/scraper'; +import { listImageModels } from '$lib/server/scraper'; import { log } from '$lib/server/logger'; export interface ImageModelInfo { @@ -12,13 +12,10 @@ export const load: PageServerLoad = async () => { // Parent layout already guards admin role. let imgModels: ImageModelInfo[] = []; try { - const res = await backendFetch('/api/admin/image-gen/models'); - if (res.ok) { - const data = await res.json(); - imgModels = (data.models ?? []) as ImageModelInfo[]; - } + imgModels = await listImageModels(); } catch (e) { log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) }); } return { imgModels }; }; + diff --git a/ui/src/routes/admin/changelog/+page.server.ts b/ui/src/routes/admin/changelog/+page.server.ts index 01d6e79..afaa7e3 100644 --- a/ui/src/routes/admin/changelog/+page.server.ts +++ b/ui/src/routes/admin/changelog/+page.server.ts @@ -1,4 +1,5 @@ import type { PageServerLoad } from './$types'; +import * as cache from '$lib/server/cache'; export interface Release { id: number; @@ -13,7 +14,15 @@ export interface Release { const GITEA_RELEASES_URL = 'https://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1'; +const CACHE_KEY = 'admin:changelog:releases'; +const CACHE_TTL = 5 * 60; // 5 minutes + export const load: PageServerLoad = async ({ fetch }) => { + const cached = await cache.get(CACHE_KEY); + if (cached) { + return { releases: cached }; + } + try { const res = await fetch(GITEA_RELEASES_URL, { headers: { Accept: 'application/json' } @@ -22,7 +31,9 @@ export const load: PageServerLoad = async ({ fetch }) => { return { releases: [], error: `Gitea API returned ${res.status}` }; } const releases: Release[] = await res.json(); - return { releases: releases.filter((r) => !r.draft) }; + const filtered = releases.filter((r) => !r.draft); + await cache.set(CACHE_KEY, filtered, CACHE_TTL); + return { releases: filtered }; } catch (e) { return { releases: [], error: String(e) }; } diff --git a/ui/src/routes/admin/image-gen/+page.server.ts b/ui/src/routes/admin/image-gen/+page.server.ts index da09808..11996e3 100644 --- a/ui/src/routes/admin/image-gen/+page.server.ts +++ b/ui/src/routes/admin/image-gen/+page.server.ts @@ -1,5 +1,5 @@ import type { PageServerLoad } from './$types'; -import { backendFetch } from '$lib/server/scraper'; +import { listImageModels } from '$lib/server/scraper'; import { log } from '$lib/server/logger'; import { listBooks } from '$lib/server/pocketbase'; @@ -21,23 +21,18 @@ export interface BookSummary { export const load: PageServerLoad = async () => { // parent layout already guards admin role - const [modelsResult, books] = await Promise.allSettled([ - (async () => { - const res = await backendFetch('/api/admin/image-gen/models'); - if (!res.ok) throw new Error(`status ${res.status}`); - const data = await res.json(); - return (data.models ?? []) as ImageModelInfo[]; - })(), + const [models, booksResult] = await Promise.allSettled([ + listImageModels(), listBooks() ]); - if (modelsResult.status === 'rejected') { - log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) }); + if (models.status === 'rejected') { + log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) }); } return { - models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]), - books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({ + models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]), + books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({ slug: b.slug, title: b.title, summary: b.summary ?? '', diff --git a/ui/src/routes/admin/image-gen/+page.svelte b/ui/src/routes/admin/image-gen/+page.svelte index a45d0ba..ed73329 100644 --- a/ui/src/routes/admin/image-gen/+page.svelte +++ b/ui/src/routes/admin/image-gen/+page.svelte @@ -355,7 +355,13 @@ const body = await res.json().catch(() => ({})); if (!res.ok) { - genError = body.error ?? body.message ?? `Error ${res.status}`; + if (res.status === 502 || res.status === 504) { + genError = + body.error ?? + `Generation timed out (${res.status}). FLUX models can take 60–120 s on Cloudflare Workers AI — try reducing steps or switching to a faster model.`; + } else { + genError = body.error ?? body.message ?? `Error ${res.status}`; + } return; } @@ -754,6 +760,13 @@ {#if showAdvanced}
+ + {#if selectedModelInfo?.provider === 'cloudflare' || selectedModelInfo?.id.toLowerCase().includes('flux')} +

+ Cloudflare Workers AI has a ~100 s timeout. High step counts on FLUX models may result in a 502 error. Keep steps ≤ 20 to stay within limits. +

+ {/if} +
diff --git a/ui/src/routes/admin/text-gen/+page.server.ts b/ui/src/routes/admin/text-gen/+page.server.ts index 165f5ac..cc04677 100644 --- a/ui/src/routes/admin/text-gen/+page.server.ts +++ b/ui/src/routes/admin/text-gen/+page.server.ts @@ -1,5 +1,5 @@ import type { PageServerLoad } from './$types'; -import { backendFetch } from '$lib/server/scraper'; +import { listTextModels } from '$lib/server/scraper'; import { log } from '$lib/server/logger'; import { listBooks } from '$lib/server/pocketbase'; @@ -18,23 +18,18 @@ export interface TextModelInfo { export const load: PageServerLoad = async () => { // Parent layout already guards admin role. - 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[]; - })(), + const [models, booksResult] = await Promise.allSettled([ + listTextModels(), listBooks() ]); - if (modelsResult.status === 'rejected') { - log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) }); + if (models.status === 'rejected') { + log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) }); } return { - models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]), - books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({ + models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]), + books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({ slug: b.slug, title: b.title })) as BookSummary[] diff --git a/ui/src/routes/api/admin/ai-jobs/[id]/cancel/+server.ts b/ui/src/routes/api/admin/ai-jobs/[id]/cancel/+server.ts new file mode 100644 index 0000000..bc5e5db --- /dev/null +++ b/ui/src/routes/api/admin/ai-jobs/[id]/cancel/+server.ts @@ -0,0 +1,29 @@ +/** + * POST /api/admin/ai-jobs/[id]/cancel + * + * Admin-only proxy to the Go backend's AI job cancel endpoint. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const POST: RequestHandler = async ({ params, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const { id } = params; + + let res: Response; + try { + res = await backendFetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' }); + } catch (e) { + log.error('admin/ai-jobs/cancel', 'backend proxy error', { id, err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +};