Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2d1a2603 | ||
|
|
b0d8c02787 | ||
|
|
5b4c1db931 |
@@ -10,6 +10,10 @@ import (
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// chapterNamesBatchSize is the number of chapters sent per LLM request.
|
||||
// Keeps output well within the 4096-token response limit (~30 tokens/title).
|
||||
const chapterNamesBatchSize = 100
|
||||
|
||||
// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
|
||||
// Returns the list of supported Cloudflare AI text generation models.
|
||||
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -36,16 +40,6 @@ type textGenChapterNamesRequest struct {
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenChapterNamesResponse is the JSON body returned by POST /api/admin/text-gen/chapter-names.
|
||||
type textGenChapterNamesResponse struct {
|
||||
// Chapters is the list of proposed chapter titles, indexed by chapter number.
|
||||
Chapters []proposedChapterTitle `json:"chapters"`
|
||||
// Model is the model that was used.
|
||||
Model string `json:"model"`
|
||||
// RawResponse is the raw model output for debugging / manual editing.
|
||||
RawResponse string `json:"raw_response"`
|
||||
}
|
||||
|
||||
// proposedChapterTitle is a single chapter with its AI-proposed title.
|
||||
type proposedChapterTitle struct {
|
||||
Number int `json:"number"`
|
||||
@@ -55,12 +49,35 @@ type proposedChapterTitle struct {
|
||||
NewTitle string `json:"new_title"`
|
||||
}
|
||||
|
||||
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
|
||||
type chapterNamesBatchEvent struct {
|
||||
// Batch is the 1-based batch index.
|
||||
Batch int `json:"batch"`
|
||||
// TotalBatches is the total number of batches.
|
||||
TotalBatches int `json:"total_batches"`
|
||||
// ChaptersDone is the cumulative count of chapters processed so far.
|
||||
ChaptersDone int `json:"chapters_done"`
|
||||
// TotalChapters is the total chapter count for this book.
|
||||
TotalChapters int `json:"total_chapters"`
|
||||
// Model is the CF AI model used.
|
||||
Model string `json:"model"`
|
||||
// Chapters contains the proposed titles for this batch.
|
||||
Chapters []proposedChapterTitle `json:"chapters"`
|
||||
// Error is non-empty if this batch failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
// Done is true on the final sentinel event (no Chapters).
|
||||
Done bool `json:"done,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
|
||||
//
|
||||
// Reads all chapter titles for the given slug, sends them to the LLM with the
|
||||
// requested naming pattern, and returns proposed replacements. Does NOT persist
|
||||
// anything — the frontend shows a diff and the user must confirm via
|
||||
// POST /api/admin/text-gen/chapter-names/apply.
|
||||
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
|
||||
// to the LLM sequentially, and streams results back as Server-Sent Events so
|
||||
// the frontend can show live progress. Each SSE data line is a JSON-encoded
|
||||
// chapterNamesBatchEvent. The final event has Done=true.
|
||||
//
|
||||
// Does NOT persist anything — the frontend shows a diff and the user must
|
||||
// confirm via POST /api/admin/text-gen/chapter-names/apply.
|
||||
func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
@@ -92,11 +109,29 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
// Build the prompt.
|
||||
var chapterListSB strings.Builder
|
||||
for _, ch := range chapters {
|
||||
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
// 4096 tokens comfortably fits 100 chapter titles (~30 tokens each).
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
|
||||
// Index existing titles for old/new diff.
|
||||
existing := make(map[int]string, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
existing[ch.Number] = ch.Title
|
||||
}
|
||||
|
||||
// Partition chapters into batches.
|
||||
batches := chunkChapters(chapters, chapterNamesBatchSize)
|
||||
totalBatches := len(batches)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen chapter-names requested",
|
||||
"slug", req.Slug, "chapters", len(chapters),
|
||||
"batches", totalBatches, "model", model, "max_tokens", maxTokens)
|
||||
|
||||
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
|
||||
`The user provides a list of chapter numbers with their current titles, ` +
|
||||
@@ -111,64 +146,91 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
`5. Each element: {"number": <int>, "title": <string>}. ` +
|
||||
`6. Output every chapter in the input list, in order. Do not skip any.`
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"Naming pattern: %s\n\nChapters:\n%s",
|
||||
req.Pattern,
|
||||
chapterListSB.String(),
|
||||
)
|
||||
// Switch to SSE before writing anything.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
sseWrite := func(evt chapterNamesBatchEvent) {
|
||||
b, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 4096 tokens so large chapter lists are not truncated.
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
chaptersDone := 0
|
||||
for i, batch := range batches {
|
||||
if r.Context().Err() != nil {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: text-gen chapter-names requested",
|
||||
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
|
||||
var chapterListSB strings.Builder
|
||||
for _, ch := range batch {
|
||||
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
|
||||
}
|
||||
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen chapter-names failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
|
||||
"batch", i+1, "err", genErr)
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Error: genErr.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the JSON array from the model response.
|
||||
proposed := parseChapterTitlesJSON(raw)
|
||||
proposed := parseChapterTitlesJSON(raw)
|
||||
result := make([]proposedChapterTitle, 0, len(proposed))
|
||||
for _, p := range proposed {
|
||||
result = append(result, proposedChapterTitle{
|
||||
Number: p.Number,
|
||||
OldTitle: existing[p.Number],
|
||||
NewTitle: p.Title,
|
||||
})
|
||||
}
|
||||
chaptersDone += len(batch)
|
||||
|
||||
// Build the response: merge proposed titles with old titles.
|
||||
// Index existing chapters by number for O(1) lookup.
|
||||
existing := make(map[int]string, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
existing[ch.Number] = ch.Title
|
||||
}
|
||||
|
||||
result := make([]proposedChapterTitle, 0, len(proposed))
|
||||
for _, p := range proposed {
|
||||
result = append(result, proposedChapterTitle{
|
||||
Number: p.Number,
|
||||
OldTitle: existing[p.Number],
|
||||
NewTitle: p.Title,
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Chapters: result,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenChapterNamesResponse{
|
||||
Chapters: result,
|
||||
Model: string(model),
|
||||
RawResponse: raw,
|
||||
})
|
||||
// Final sentinel event.
|
||||
sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
|
||||
}
|
||||
|
||||
// chunkChapters splits a chapter slice into batches of at most size n.
|
||||
func chunkChapters(chapters []domain.ChapterInfo, n int) [][]domain.ChapterInfo {
|
||||
var batches [][]domain.ChapterInfo
|
||||
for len(chapters) > 0 {
|
||||
end := n
|
||||
if end > len(chapters) {
|
||||
end = len(chapters)
|
||||
}
|
||||
batches = append(batches, chapters[:end])
|
||||
chapters = chapters[end:]
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
// parseChapterTitlesJSON extracts the JSON array from a model response.
|
||||
|
||||
@@ -130,7 +130,14 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
|
||||
// Set created timestamp on first insert so recentlyUpdatedBooks can sort by it.
|
||||
insertPayload := map[string]any{
|
||||
"slug": slug,
|
||||
"number": ref.Number,
|
||||
"title": ref.Title,
|
||||
"created": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", insertPayload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
image: kalekber/libnovel-runner:latest
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
depends_on:
|
||||
- libretranslate
|
||||
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
|
||||
|
||||
@@ -149,9 +149,10 @@ create "books" '{
|
||||
|
||||
create "chapters_idx" '{
|
||||
"name":"chapters_idx","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"number","type":"number", "required":true},
|
||||
{"name":"title", "type":"text"}
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"number", "type":"number", "required":true},
|
||||
{"name":"title", "type":"text"},
|
||||
{"name":"created", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "ranking" '{
|
||||
@@ -326,9 +327,12 @@ add_field "app_users" "polar_customer_id" "text"
|
||||
add_field "app_users" "polar_subscription_id" "text"
|
||||
add_field "user_library" "shelf" "text"
|
||||
add_field "user_sessions" "device_fingerprint" "text"
|
||||
add_field "chapters_idx" "created" "date"
|
||||
|
||||
# ── 6. Indexes ────────────────────────────────────────────────────────────────
|
||||
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
|
||||
"CREATE UNIQUE INDEX idx_chapters_idx_slug_number ON chapters_idx (slug, number)"
|
||||
add_index "chapters_idx" "idx_chapters_idx_created" \
|
||||
"CREATE INDEX idx_chapters_idx_created ON chapters_idx (created)"
|
||||
|
||||
log "done"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 ImageModelInfo {
|
||||
id: string;
|
||||
@@ -11,18 +12,36 @@ export interface ImageModelInfo {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BookSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
cover: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// parent layout already guards admin role
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) {
|
||||
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
|
||||
return { models: [] as ImageModelInfo[] };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { models: (data.models ?? []) as ImageModelInfo[] };
|
||||
} catch (e) {
|
||||
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
|
||||
return { models: [] as ImageModelInfo[] };
|
||||
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[];
|
||||
})(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
cover: b.cover ?? ''
|
||||
})) as BookSummary[]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,26 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { ImageModelInfo } from './+page.server';
|
||||
import type { ImageModelInfo, BookSummary } from './+page.server';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────────────────────
|
||||
type ImageType = 'cover' | 'chapter';
|
||||
|
||||
const CONFIG_KEY = 'admin_image_gen_config_v1';
|
||||
|
||||
interface SavedConfig {
|
||||
selectedModel: string;
|
||||
numSteps: number;
|
||||
guidance: number;
|
||||
strength: number;
|
||||
width: number;
|
||||
height: number;
|
||||
showAdvanced: boolean;
|
||||
}
|
||||
|
||||
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, numSteps, guidance, strength, width, height, showAdvanced };
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
const saved = loadConfig();
|
||||
|
||||
let imageType = $state<ImageType>('cover');
|
||||
let slug = $state('');
|
||||
let chapter = $state<number>(1);
|
||||
let selectedModel = $state('');
|
||||
let selectedModel = $state(saved.selectedModel ?? '');
|
||||
let prompt = $state('');
|
||||
let referenceFile = $state<File | null>(null);
|
||||
let referencePreviewUrl = $state('');
|
||||
let useCoverAsRef = $state(false);
|
||||
|
||||
// Advanced
|
||||
let showAdvanced = $state(false);
|
||||
let numSteps = $state(20);
|
||||
let guidance = $state(7.5);
|
||||
let strength = $state(0.75);
|
||||
let width = $state(1024);
|
||||
let height = $state(1024);
|
||||
let showAdvanced = $state(saved.showAdvanced ?? false);
|
||||
let numSteps = $state(saved.numSteps ?? 20);
|
||||
let guidance = $state(saved.guidance ?? 7.5);
|
||||
let strength = $state(saved.strength ?? 0.75);
|
||||
let width = $state(saved.width ?? 1024);
|
||||
let height = $state(saved.height ?? 1024);
|
||||
|
||||
// Persist config on change
|
||||
$effect(() => {
|
||||
void selectedModel; void numSteps; void guidance; void strength;
|
||||
void width; void height; void showAdvanced;
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// ── Book autocomplete ────────────────────────────────────────────────────────
|
||||
const books = data.books as BookSummary[];
|
||||
let slugInput = $state('');
|
||||
let slugFocused = $state(false);
|
||||
let selectedBook = $state<BookSummary | null>(null);
|
||||
|
||||
let bookSuggestions = $derived(
|
||||
slugInput.trim().length === 0
|
||||
? []
|
||||
: books
|
||||
.filter((b) =>
|
||||
b.slug.includes(slugInput.toLowerCase()) ||
|
||||
b.title.toLowerCase().includes(slugInput.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
function selectBook(b: BookSummary) {
|
||||
selectedBook = b;
|
||||
slug = b.slug;
|
||||
slugInput = b.slug;
|
||||
slugFocused = false;
|
||||
// Reset cover-as-ref if no cover
|
||||
if (!b.cover) useCoverAsRef = false;
|
||||
}
|
||||
|
||||
function onSlugInput() {
|
||||
slug = slugInput;
|
||||
// If user edits away from selected book slug, deselect
|
||||
if (selectedBook && slugInput !== selectedBook.slug) {
|
||||
selectedBook = null;
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
}
|
||||
|
||||
// When useCoverAsRef toggled on, load the book cover as reference
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!useCoverAsRef || !selectedBook?.cover) {
|
||||
if (useCoverAsRef) useCoverAsRef = false;
|
||||
return;
|
||||
}
|
||||
// Fetch the cover image and set as referenceFile
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(selectedBook!.cover);
|
||||
const blob = await res.blob();
|
||||
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
|
||||
const file = new File([blob], `${selectedBook!.slug}-cover.${ext}`, { type: blob.type });
|
||||
handleReferenceFile(file);
|
||||
} catch {
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// ── Generation state ─────────────────────────────────────────────────────────
|
||||
let generating = $state(false);
|
||||
@@ -52,16 +146,10 @@
|
||||
// ── Model helpers ────────────────────────────────────────────────────────────
|
||||
const models = data.models as ImageModelInfo[];
|
||||
|
||||
let filteredModels = $derived(
|
||||
referenceFile
|
||||
? models // show all; warn on ones without ref support
|
||||
: models
|
||||
);
|
||||
|
||||
let coverModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('cover')));
|
||||
let chapterModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('chapter')));
|
||||
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
|
||||
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
|
||||
let otherModels = $derived(
|
||||
filteredModels.filter(
|
||||
models.filter(
|
||||
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
|
||||
)
|
||||
);
|
||||
@@ -74,12 +162,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Reset model selection when type changes if current selection no longer fits
|
||||
$effect(() => {
|
||||
void imageType; // track
|
||||
void imageType;
|
||||
const preferred = imageType === 'cover' ? coverModels : chapterModels;
|
||||
if (preferred.length > 0) {
|
||||
// only auto-switch if current model isn't in preferred list for this type
|
||||
const current = models.find((m) => m.id === selectedModel);
|
||||
if (!current || !current.recommended_for.includes(imageType)) {
|
||||
selectedModel = preferred[0].id;
|
||||
@@ -90,14 +176,21 @@
|
||||
// ── Prompt templates ────────────────────────────────────────────────────────
|
||||
let promptTemplate = $derived(
|
||||
imageType === 'cover'
|
||||
? `Book cover for "${slug || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
|
||||
: `Illustration for chapter ${chapter} of "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||
? `Book cover for "${slugInput || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
|
||||
: `Illustration for chapter ${chapter} of "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||
);
|
||||
|
||||
function applyTemplate() {
|
||||
prompt = promptTemplate;
|
||||
}
|
||||
|
||||
function injectDescription() {
|
||||
const desc = selectedBook?.summary?.trim();
|
||||
if (!desc) return;
|
||||
const snippet = desc.length > 300 ? desc.slice(0, 300) + '…' : desc;
|
||||
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
|
||||
}
|
||||
|
||||
// ── Reference image handling ─────────────────────────────────────────────────
|
||||
let dragOver = $state(false);
|
||||
|
||||
@@ -110,17 +203,22 @@
|
||||
function onFileInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
handleReferenceFile(input.files?.[0] ?? null);
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
handleReferenceFile(file);
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearReference() {
|
||||
handleReferenceFile(null);
|
||||
useCoverAsRef = false;
|
||||
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
|
||||
if (input) input.value = '';
|
||||
}
|
||||
@@ -219,7 +317,6 @@
|
||||
saveSuccess = false;
|
||||
|
||||
try {
|
||||
// Extract the raw base64 from the data URL (data:<mime>;base64,<b64>)
|
||||
const b64 = result.imageSrc.split(',')[1];
|
||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
@@ -308,13 +405,63 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="slug-input"
|
||||
type="text"
|
||||
bind:value={slug}
|
||||
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)"
|
||||
/>
|
||||
<!-- Autocomplete wrapper -->
|
||||
<div class="relative">
|
||||
<input
|
||||
id="slug-input"
|
||||
type="text"
|
||||
bind:value={slugInput}
|
||||
oninput={onSlugInput}
|
||||
onfocus={() => (slugFocused = true)}
|
||||
onblur={() => setTimeout(() => { slugFocused = 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 slugFocused && bookSuggestions.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 bookSuggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={selectedBook?.slug === b.slug}
|
||||
onmousedown={() => selectBook(b)}
|
||||
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
{#if b.cover}
|
||||
<img src={b.cover} alt="" class="w-8 h-10 object-cover rounded shrink-0" />
|
||||
{:else}
|
||||
<div class="w-8 h-10 rounded bg-(--color-surface-3) shrink-0"></div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate font-mono">{b.slug}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Book info pill when a book is selected -->
|
||||
{#if selectedBook}
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs text-(--color-success) flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{selectedBook.title}
|
||||
</span>
|
||||
{#if selectedBook.summary}
|
||||
<button
|
||||
onclick={injectDescription}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
title="Append book description to prompt"
|
||||
>
|
||||
+ inject description
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if imageType === 'chapter'}
|
||||
@@ -385,12 +532,22 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
|
||||
Prompt
|
||||
</label>
|
||||
<button
|
||||
onclick={applyTemplate}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Use template
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if selectedBook?.summary}
|
||||
<button
|
||||
onclick={injectDescription}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Inject description
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={applyTemplate}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
Use template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="prompt-input"
|
||||
@@ -403,9 +560,26 @@
|
||||
|
||||
<!-- Reference image drop zone -->
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
|
||||
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
|
||||
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
|
||||
</p>
|
||||
{#if selectedBook?.cover && selectedModelInfo?.supports_ref}
|
||||
<label class="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={useCoverAsRef}
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') useCoverAsRef = !useCoverAsRef; }}
|
||||
onclick={() => (useCoverAsRef = !useCoverAsRef)}
|
||||
class="w-8 h-4 rounded-full transition-colors relative {useCoverAsRef ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
|
||||
>
|
||||
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform {useCoverAsRef ? 'translate-x-4' : ''}"></span>
|
||||
</div>
|
||||
<span class="text-xs text-(--color-muted)">Use book cover</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if referenceFile && referencePreviewUrl}
|
||||
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
|
||||
<img
|
||||
@@ -416,6 +590,9 @@
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="text-sm text-(--color-text) truncate">{referenceFile.name}</p>
|
||||
<p class="text-xs text-(--color-muted)">{fmtBytes(referenceFile.size)}</p>
|
||||
{#if useCoverAsRef}
|
||||
<p class="text-xs text-(--color-brand)">Current book cover</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={clearReference}
|
||||
@@ -530,7 +707,6 @@
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if generating}
|
||||
<!-- Spinner -->
|
||||
<svg class="w-4 h-4 animate-spin" 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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* POST /api/admin/text-gen/chapter-names
|
||||
*
|
||||
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
|
||||
* Returns AI-proposed chapter titles; does NOT persist anything.
|
||||
* The backend streams SSE events; this handler pipes the stream through
|
||||
* directly so the browser can consume it without buffering.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
@@ -28,6 +29,22 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
// Non-2xx: the backend returned a JSON error before switching to SSE.
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Pipe the SSE stream straight through — do not buffer.
|
||||
return new Response(res.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user