Compare commits
2 Commits
v2.5.75
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40151f2f33 | ||
|
|
ad2d1a2603 |
@@ -10,6 +10,10 @@ import (
|
|||||||
"github.com/libnovel/backend/internal/domain"
|
"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.
|
// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
|
||||||
// Returns the list of supported Cloudflare AI text generation models.
|
// Returns the list of supported Cloudflare AI text generation models.
|
||||||
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -36,16 +40,6 @@ type textGenChapterNamesRequest struct {
|
|||||||
MaxTokens int `json:"max_tokens"`
|
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.
|
// proposedChapterTitle is a single chapter with its AI-proposed title.
|
||||||
type proposedChapterTitle struct {
|
type proposedChapterTitle struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
@@ -55,12 +49,35 @@ type proposedChapterTitle struct {
|
|||||||
NewTitle string `json:"new_title"`
|
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.
|
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
|
||||||
//
|
//
|
||||||
// Reads all chapter titles for the given slug, sends them to the LLM with the
|
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
|
||||||
// requested naming pattern, and returns proposed replacements. Does NOT persist
|
// to the LLM sequentially, and streams results back as Server-Sent Events so
|
||||||
// anything — the frontend shows a diff and the user must confirm via
|
// the frontend can show live progress. Each SSE data line is a JSON-encoded
|
||||||
// POST /api/admin/text-gen/chapter-names/apply.
|
// 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) {
|
func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.deps.TextGen == nil {
|
if s.deps.TextGen == nil {
|
||||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the prompt.
|
model := cfai.TextModel(req.Model)
|
||||||
var chapterListSB strings.Builder
|
if model == "" {
|
||||||
for _, ch := range chapters {
|
model = cfai.DefaultTextModel
|
||||||
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
|
|
||||||
}
|
}
|
||||||
|
// 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. ` +
|
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
|
||||||
`The user provides a list of chapter numbers with their current titles, ` +
|
`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>}. ` +
|
`5. Each element: {"number": <int>, "title": <string>}. ` +
|
||||||
`6. Output every chapter in the input list, in order. Do not skip any.`
|
`6. Output every chapter in the input list, in order. Do not skip any.`
|
||||||
|
|
||||||
userPrompt := fmt.Sprintf(
|
// Switch to SSE before writing anything.
|
||||||
"Naming pattern: %s\n\nChapters:\n%s",
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
req.Pattern,
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
chapterListSB.String(),
|
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||||
)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
model := cfai.TextModel(req.Model)
|
sseWrite := func(evt chapterNamesBatchEvent) {
|
||||||
if model == "" {
|
b, _ := json.Marshal(evt)
|
||||||
model = cfai.DefaultTextModel
|
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to 4096 tokens so large chapter lists are not truncated.
|
chaptersDone := 0
|
||||||
maxTokens := req.MaxTokens
|
for i, batch := range batches {
|
||||||
if maxTokens <= 0 {
|
if r.Context().Err() != nil {
|
||||||
maxTokens = 4096
|
return // client disconnected
|
||||||
}
|
}
|
||||||
|
|
||||||
s.deps.Log.Info("admin: text-gen chapter-names requested",
|
var chapterListSB strings.Builder
|
||||||
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
|
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{
|
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||||
Model: model,
|
Model: model,
|
||||||
Messages: []cfai.TextMessage{
|
Messages: []cfai.TextMessage{
|
||||||
{Role: "system", Content: systemPrompt},
|
{Role: "system", Content: systemPrompt},
|
||||||
{Role: "user", Content: userPrompt},
|
{Role: "user", Content: userPrompt},
|
||||||
},
|
},
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
})
|
})
|
||||||
if genErr != nil {
|
if genErr != nil {
|
||||||
s.deps.Log.Error("admin: text-gen chapter-names failed", "err", genErr)
|
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
|
||||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
"batch", i+1, "err", genErr)
|
||||||
return
|
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.
|
sseWrite(chapterNamesBatchEvent{
|
||||||
// Index existing chapters by number for O(1) lookup.
|
Batch: i + 1,
|
||||||
existing := make(map[int]string, len(chapters))
|
TotalBatches: totalBatches,
|
||||||
for _, ch := range chapters {
|
ChaptersDone: chaptersDone,
|
||||||
existing[ch.Number] = ch.Title
|
TotalChapters: len(chapters),
|
||||||
}
|
Model: string(model),
|
||||||
|
Chapters: result,
|
||||||
result := make([]proposedChapterTitle, 0, len(proposed))
|
|
||||||
for _, p := range proposed {
|
|
||||||
result = append(result, proposedChapterTitle{
|
|
||||||
Number: p.Number,
|
|
||||||
OldTitle: existing[p.Number],
|
|
||||||
NewTitle: p.Title,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, 0, textGenChapterNamesResponse{
|
// Final sentinel event.
|
||||||
Chapters: result,
|
sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
|
||||||
Model: string(model),
|
}
|
||||||
RawResponse: raw,
|
|
||||||
})
|
// 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.
|
// parseChapterTitlesJSON extracts the JSON array from a model response.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { backendFetch } from '$lib/server/scraper';
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
import { listBooks } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
export interface ImageModelInfo {
|
export interface ImageModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,18 +12,36 @@ export interface ImageModelInfo {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookSummary {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
cover: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
// parent layout already guards admin role
|
// parent layout already guards admin role
|
||||||
try {
|
const [modelsResult, books] = await Promise.allSettled([
|
||||||
const res = await backendFetch('/api/admin/image-gen/models');
|
(async () => {
|
||||||
if (!res.ok) {
|
const res = await backendFetch('/api/admin/image-gen/models');
|
||||||
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
return { models: [] as ImageModelInfo[] };
|
const data = await res.json();
|
||||||
}
|
return (data.models ?? []) as ImageModelInfo[];
|
||||||
const data = await res.json();
|
})(),
|
||||||
return { models: (data.models ?? []) as ImageModelInfo[] };
|
listBooks()
|
||||||
} catch (e) {
|
]);
|
||||||
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
|
|
||||||
return { models: [] as ImageModelInfo[] };
|
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">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { ImageModelInfo } from './+page.server';
|
import type { ImageModelInfo, BookSummary } from './+page.server';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// ── Form state ───────────────────────────────────────────────────────────────
|
// ── Form state ───────────────────────────────────────────────────────────────
|
||||||
type ImageType = 'cover' | 'chapter';
|
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 imageType = $state<ImageType>('cover');
|
||||||
let slug = $state('');
|
let slug = $state('');
|
||||||
let chapter = $state<number>(1);
|
let chapter = $state<number>(1);
|
||||||
let selectedModel = $state('');
|
let selectedModel = $state(saved.selectedModel ?? '');
|
||||||
let prompt = $state('');
|
let prompt = $state('');
|
||||||
let referenceFile = $state<File | null>(null);
|
let referenceFile = $state<File | null>(null);
|
||||||
let referencePreviewUrl = $state('');
|
let referencePreviewUrl = $state('');
|
||||||
|
let useCoverAsRef = $state(false);
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
let showAdvanced = $state(false);
|
let showAdvanced = $state(saved.showAdvanced ?? false);
|
||||||
let numSteps = $state(20);
|
let numSteps = $state(saved.numSteps ?? 20);
|
||||||
let guidance = $state(7.5);
|
let guidance = $state(saved.guidance ?? 7.5);
|
||||||
let strength = $state(0.75);
|
let strength = $state(saved.strength ?? 0.75);
|
||||||
let width = $state(1024);
|
let width = $state(saved.width ?? 1024);
|
||||||
let height = $state(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 ─────────────────────────────────────────────────────────
|
// ── Generation state ─────────────────────────────────────────────────────────
|
||||||
let generating = $state(false);
|
let generating = $state(false);
|
||||||
@@ -52,16 +146,10 @@
|
|||||||
// ── Model helpers ────────────────────────────────────────────────────────────
|
// ── Model helpers ────────────────────────────────────────────────────────────
|
||||||
const models = data.models as ImageModelInfo[];
|
const models = data.models as ImageModelInfo[];
|
||||||
|
|
||||||
let filteredModels = $derived(
|
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
|
||||||
referenceFile
|
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
|
||||||
? 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 otherModels = $derived(
|
let otherModels = $derived(
|
||||||
filteredModels.filter(
|
models.filter(
|
||||||
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
|
(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(() => {
|
$effect(() => {
|
||||||
void imageType; // track
|
void imageType;
|
||||||
const preferred = imageType === 'cover' ? coverModels : chapterModels;
|
const preferred = imageType === 'cover' ? coverModels : chapterModels;
|
||||||
if (preferred.length > 0) {
|
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);
|
const current = models.find((m) => m.id === selectedModel);
|
||||||
if (!current || !current.recommended_for.includes(imageType)) {
|
if (!current || !current.recommended_for.includes(imageType)) {
|
||||||
selectedModel = preferred[0].id;
|
selectedModel = preferred[0].id;
|
||||||
@@ -90,14 +176,21 @@
|
|||||||
// ── Prompt templates ────────────────────────────────────────────────────────
|
// ── Prompt templates ────────────────────────────────────────────────────────
|
||||||
let promptTemplate = $derived(
|
let promptTemplate = $derived(
|
||||||
imageType === 'cover'
|
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.`
|
? `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 "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
: `Illustration for chapter ${chapter} of "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||||
);
|
);
|
||||||
|
|
||||||
function applyTemplate() {
|
function applyTemplate() {
|
||||||
prompt = promptTemplate;
|
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 ─────────────────────────────────────────────────
|
// ── Reference image handling ─────────────────────────────────────────────────
|
||||||
let dragOver = $state(false);
|
let dragOver = $state(false);
|
||||||
|
|
||||||
@@ -110,17 +203,22 @@
|
|||||||
function onFileInput(e: Event) {
|
function onFileInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
handleReferenceFile(input.files?.[0] ?? null);
|
handleReferenceFile(input.files?.[0] ?? null);
|
||||||
|
useCoverAsRef = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
function onDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragOver = false;
|
dragOver = false;
|
||||||
const file = e.dataTransfer?.files[0];
|
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() {
|
function clearReference() {
|
||||||
handleReferenceFile(null);
|
handleReferenceFile(null);
|
||||||
|
useCoverAsRef = false;
|
||||||
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
|
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
|
||||||
if (input) input.value = '';
|
if (input) input.value = '';
|
||||||
}
|
}
|
||||||
@@ -219,7 +317,6 @@
|
|||||||
saveSuccess = false;
|
saveSuccess = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract the raw base64 from the data URL (data:<mime>;base64,<b64>)
|
|
||||||
const b64 = result.imageSrc.split(',')[1];
|
const b64 = result.imageSrc.split(',')[1];
|
||||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -308,13 +405,63 @@
|
|||||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
|
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
|
||||||
Book slug
|
Book slug
|
||||||
</label>
|
</label>
|
||||||
<input
|
<!-- Autocomplete wrapper -->
|
||||||
id="slug-input"
|
<div class="relative">
|
||||||
type="text"
|
<input
|
||||||
bind:value={slug}
|
id="slug-input"
|
||||||
placeholder="e.g. shadow-slave"
|
type="text"
|
||||||
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)"
|
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>
|
</div>
|
||||||
|
|
||||||
{#if imageType === 'chapter'}
|
{#if imageType === 'chapter'}
|
||||||
@@ -385,12 +532,22 @@
|
|||||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
|
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
|
||||||
Prompt
|
Prompt
|
||||||
</label>
|
</label>
|
||||||
<button
|
<div class="flex items-center gap-3">
|
||||||
onclick={applyTemplate}
|
{#if selectedBook?.summary}
|
||||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
<button
|
||||||
>
|
onclick={injectDescription}
|
||||||
Use template
|
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||||
</button>
|
>
|
||||||
|
Inject description
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={applyTemplate}
|
||||||
|
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||||
|
>
|
||||||
|
Use template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
id="prompt-input"
|
id="prompt-input"
|
||||||
@@ -403,9 +560,26 @@
|
|||||||
|
|
||||||
<!-- Reference image drop zone -->
|
<!-- Reference image drop zone -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
|
<div class="flex items-center justify-between">
|
||||||
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
|
||||||
</p>
|
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}
|
{#if referenceFile && referencePreviewUrl}
|
||||||
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
|
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
|
||||||
<img
|
<img
|
||||||
@@ -416,6 +590,9 @@
|
|||||||
<div class="min-w-0 flex-1 space-y-0.5">
|
<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-sm text-(--color-text) truncate">{referenceFile.name}</p>
|
||||||
<p class="text-xs text-(--color-muted)">{fmtBytes(referenceFile.size)}</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={clearReference}
|
onclick={clearReference}
|
||||||
@@ -530,7 +707,6 @@
|
|||||||
flex items-center justify-center gap-2"
|
flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{#if generating}
|
{#if generating}
|
||||||
<!-- Spinner -->
|
|
||||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
<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" />
|
<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" />
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { backendFetch } from '$lib/server/scraper';
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
import { listBooks } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
export interface BookSummary {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TextModelInfo {
|
export interface TextModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,16 +18,25 @@ export interface TextModelInfo {
|
|||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
// Parent layout already guards admin role.
|
// Parent layout already guards admin role.
|
||||||
try {
|
const [modelsResult, books] = await Promise.allSettled([
|
||||||
const res = await backendFetch('/api/admin/text-gen/models');
|
(async () => {
|
||||||
if (!res.ok) {
|
const res = await backendFetch('/api/admin/text-gen/models');
|
||||||
log.warn('admin/text-gen', 'failed to load models', { status: res.status });
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
return { models: [] as TextModelInfo[] };
|
const data = await res.json();
|
||||||
}
|
return (data.models ?? []) as TextModelInfo[];
|
||||||
const data = await res.json();
|
})(),
|
||||||
return { models: (data.models ?? []) as TextModelInfo[] };
|
listBooks()
|
||||||
} catch (e) {
|
]);
|
||||||
log.warn('admin/text-gen', 'backend unreachable', { err: String(e) });
|
|
||||||
return { models: [] as TextModelInfo[] };
|
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">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { TextModelInfo } from './+page.server';
|
import type { TextModelInfo, BookSummary } from './+page.server';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const models = data.models as TextModelInfo[];
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Shared ────────────────────────────────────────────────────────────────────
|
||||||
type ActiveTab = 'chapters' | 'description';
|
type ActiveTab = 'chapters' | 'description';
|
||||||
let activeTab = $state<ActiveTab>('chapters');
|
let activeTab = $state<ActiveTab>(saved.activeTab ?? 'chapters');
|
||||||
let selectedModel = $state(models[0]?.id ?? '');
|
let selectedModel = $state(saved.selectedModel ?? (models[0]?.id ?? ''));
|
||||||
|
|
||||||
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
|
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
|
||||||
|
|
||||||
|
$effect(() => { void selectedModel; void activeTab; void chPattern; void dInstructions; saveConfig(); });
|
||||||
|
|
||||||
function fmtCtx(n: number) {
|
function fmtCtx(n: number) {
|
||||||
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
|
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
|
||||||
return `${n} 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 ───────────────────────────────────────────────────────
|
// ── Chapter names state ───────────────────────────────────────────────────────
|
||||||
|
let chAC = makeBookAC();
|
||||||
let chSlug = $state('');
|
let chSlug = $state('');
|
||||||
let chPattern = $state('Chapter {n}: {scene}');
|
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
|
||||||
let chGenerating = $state(false);
|
let chGenerating = $state(false);
|
||||||
let chError = $state('');
|
let chError = $state('');
|
||||||
|
|
||||||
@@ -28,13 +84,13 @@
|
|||||||
number: number;
|
number: number;
|
||||||
old_title: string;
|
old_title: string;
|
||||||
new_title: string;
|
new_title: string;
|
||||||
// editable copy
|
|
||||||
edited: string;
|
edited: string;
|
||||||
}
|
}
|
||||||
let chProposals = $state<ProposedChapter[]>([]);
|
let chProposals = $state<ProposedChapter[]>([]);
|
||||||
let chRawResponse = $state('');
|
|
||||||
let chUsedModel = $state('');
|
let chUsedModel = $state('');
|
||||||
let chShowRaw = $state(false);
|
|
||||||
|
let chBatchProgress = $state('');
|
||||||
|
let chBatchWarnings = $state<string[]>([]);
|
||||||
|
|
||||||
let chApplying = $state(false);
|
let chApplying = $state(false);
|
||||||
let chApplyError = $state('');
|
let chApplyError = $state('');
|
||||||
@@ -43,11 +99,24 @@
|
|||||||
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
|
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
|
||||||
let chCanApply = $derived(chProposals.length > 0 && !chApplying);
|
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() {
|
async function generateChapterNames() {
|
||||||
if (!chCanGenerate) return;
|
if (!chCanGenerate) return;
|
||||||
chGenerating = true;
|
chGenerating = true;
|
||||||
chError = '';
|
chError = '';
|
||||||
chProposals = [];
|
chProposals = [];
|
||||||
|
chUsedModel = '';
|
||||||
|
chBatchProgress = '';
|
||||||
|
chBatchWarnings = [];
|
||||||
chApplySuccess = false;
|
chApplySuccess = false;
|
||||||
chApplyError = '';
|
chApplyError = '';
|
||||||
|
|
||||||
@@ -61,20 +130,77 @@
|
|||||||
model: selectedModel
|
model: selectedModel
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
|
// Non-SSE error response (e.g. 400/404/502 before streaming started).
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
chError = body.error ?? body.message ?? `Error ${res.status}`;
|
chError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
chProposals = ((body.chapters ?? []) as { number: number; old_title: string; new_title: string }[]).map(
|
|
||||||
(p) => ({ ...p, edited: p.new_title })
|
// Stream SSE events line by line.
|
||||||
);
|
const reader = res.body!.getReader();
|
||||||
chRawResponse = body.raw_response ?? '';
|
const decoder = new TextDecoder();
|
||||||
chUsedModel = body.model ?? '';
|
let buffer = '';
|
||||||
// If backend returned chapters:[] but we have a raw response, the model
|
|
||||||
// output was unparseable (likely truncated). Treat it as an error.
|
outer: while (true) {
|
||||||
if (chProposals.length === 0 && chRawResponse.trim().length > 0) {
|
const { value, done } = await reader.read();
|
||||||
chError = 'Model response could not be parsed (output may be truncated). Raw response shown below.';
|
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 {
|
} catch {
|
||||||
chError = 'Network error.';
|
chError = 'Network error.';
|
||||||
@@ -112,8 +238,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Description state ─────────────────────────────────────────────────────────
|
// ── Description state ─────────────────────────────────────────────────────────
|
||||||
|
let dAC = makeBookAC();
|
||||||
let dSlug = $state('');
|
let dSlug = $state('');
|
||||||
let dInstructions = $state('');
|
let dInstructions = $state(saved.dInstructions ?? '');
|
||||||
let dGenerating = $state(false);
|
let dGenerating = $state(false);
|
||||||
let dError = $state('');
|
let dError = $state('');
|
||||||
|
|
||||||
@@ -128,6 +255,16 @@
|
|||||||
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
|
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
|
||||||
let dCanApply = $derived(dNewDesc.trim().length > 0 && !dApplying);
|
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() {
|
async function generateDescription() {
|
||||||
if (!dCanGenerate) return;
|
if (!dCanGenerate) return;
|
||||||
dGenerating = true;
|
dGenerating = true;
|
||||||
@@ -247,13 +384,37 @@
|
|||||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
|
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
|
||||||
Book slug
|
Book slug
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="relative">
|
||||||
id="ch-slug"
|
<input
|
||||||
type="text"
|
id="ch-slug"
|
||||||
bind:value={chSlug}
|
type="text"
|
||||||
placeholder="e.g. shadow-slave"
|
bind:value={chAC.inputVal}
|
||||||
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)"
|
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>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@@ -292,9 +453,6 @@
|
|||||||
|
|
||||||
{#if chError}
|
{#if chError}
|
||||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -309,16 +467,25 @@
|
|||||||
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
|
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<button
|
{#if chBatchProgress}
|
||||||
onclick={() => (chShowRaw = !chShowRaw)}
|
<span class="text-xs text-(--color-muted) flex items-center gap-1.5">
|
||||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
{#if chGenerating}
|
||||||
>
|
<svg class="w-3 h-3 animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
{chShowRaw ? 'Hide raw' : 'Show raw'}
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
</button>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
{chBatchProgress}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if chShowRaw}
|
{#if chBatchWarnings.length > 0}
|
||||||
<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>
|
<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}
|
{/if}
|
||||||
|
|
||||||
<div class="max-h-[28rem] overflow-y-auto space-y-2 pr-1">
|
<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`}
|
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
|
||||||
</button>
|
</button>
|
||||||
<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
|
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)"
|
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" />
|
<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" />
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
</svg>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -394,13 +563,37 @@
|
|||||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
|
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
|
||||||
Book slug
|
Book slug
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="relative">
|
||||||
id="d-slug"
|
<input
|
||||||
type="text"
|
id="d-slug"
|
||||||
bind:value={dSlug}
|
type="text"
|
||||||
placeholder="e.g. shadow-slave"
|
bind:value={dAC.inputVal}
|
||||||
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)"
|
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>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
* POST /api/admin/text-gen/chapter-names
|
* POST /api/admin/text-gen/chapter-names
|
||||||
*
|
*
|
||||||
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
|
* 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 type { RequestHandler } from './$types';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
import { backendFetch } from '$lib/server/scraper';
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
@@ -28,6 +29,22 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
throw error(502, 'Could not reach backend');
|
throw error(502, 'Could not reach backend');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
// Non-2xx: the backend returned a JSON error before switching to SSE.
|
||||||
return json(data, { status: res.status });
|
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -138,6 +138,21 @@
|
|||||||
let coverPreview = $state<string | null>(null);
|
let coverPreview = $state<string | null>(null);
|
||||||
let coverSaving = $state(false);
|
let coverSaving = $state(false);
|
||||||
let coverResult = $state<'saved' | 'error' | ''>('');
|
let coverResult = $state<'saved' | 'error' | ''>('');
|
||||||
|
let coverPromptOpen = $state(false);
|
||||||
|
|
||||||
|
function buildCoverPrompt(): string {
|
||||||
|
const title = data.book?.title ?? '';
|
||||||
|
const summary = data.book?.summary ?? '';
|
||||||
|
const excerpt = summary.length > 200 ? summary.slice(0, 200) + '…' : summary;
|
||||||
|
return `Book cover art for "${title}"${excerpt ? ` — ${excerpt}` : ''}. Epic scene with dramatic lighting, professional book cover, highly detailed, 4K.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let coverPrompt = $state('');
|
||||||
|
let coverUseAsRef = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (coverPromptOpen && !coverPrompt) coverPrompt = buildCoverPrompt();
|
||||||
|
});
|
||||||
|
|
||||||
async function generateCover() {
|
async function generateCover() {
|
||||||
const slug = data.book?.slug;
|
const slug = data.book?.slug;
|
||||||
@@ -145,12 +160,34 @@
|
|||||||
coverGenerating = true;
|
coverGenerating = true;
|
||||||
coverPreview = null;
|
coverPreview = null;
|
||||||
coverResult = '';
|
coverResult = '';
|
||||||
|
const promptToUse = coverPrompt.trim() || buildCoverPrompt();
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/image-gen', {
|
let res: Response;
|
||||||
method: 'POST',
|
if (coverUseAsRef && data.book?.cover) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
try {
|
||||||
body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug })
|
const imgRes = await fetch(data.book.cover);
|
||||||
});
|
const blob = await imgRes.blob();
|
||||||
|
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
|
||||||
|
const file = new File([blob], `${slug}-cover-ref.${ext}`, { type: blob.type });
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('json', JSON.stringify({ slug, type: 'cover', prompt: promptToUse, strength: 0.65 }));
|
||||||
|
fd.append('reference', file);
|
||||||
|
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
|
||||||
|
} catch {
|
||||||
|
// Fall back to text-only if cover fetch fails
|
||||||
|
res = await fetch('/api/admin/image-gen', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res = await fetch('/api/admin/image-gen', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
|
||||||
|
});
|
||||||
|
}
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
|
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
|
||||||
@@ -195,6 +232,7 @@
|
|||||||
let chapterCoverGenerating = $state(false);
|
let chapterCoverGenerating = $state(false);
|
||||||
let chapterCoverPreview = $state<string | null>(null);
|
let chapterCoverPreview = $state<string | null>(null);
|
||||||
let chapterCoverResult = $state<'error' | ''>('');
|
let chapterCoverResult = $state<'error' | ''>('');
|
||||||
|
let chapterCoverPrompt = $state('');
|
||||||
|
|
||||||
async function generateChapterCover() {
|
async function generateChapterCover() {
|
||||||
const slug = data.book?.slug;
|
const slug = data.book?.slug;
|
||||||
@@ -204,11 +242,12 @@
|
|||||||
chapterCoverGenerating = true;
|
chapterCoverGenerating = true;
|
||||||
chapterCoverPreview = null;
|
chapterCoverPreview = null;
|
||||||
chapterCoverResult = '';
|
chapterCoverResult = '';
|
||||||
|
const promptToUse = chapterCoverPrompt.trim() || `Chapter ${n} illustration for "${data.book?.title ?? slug}". Dramatic scene, vivid colors, detailed art, cinematic lighting.`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/image-gen', {
|
const res = await fetch('/api/admin/image-gen', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: data.book?.title ?? slug })
|
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: promptToUse })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
@@ -228,6 +267,7 @@
|
|||||||
let descPreview = $state('');
|
let descPreview = $state('');
|
||||||
let descApplying = $state(false);
|
let descApplying = $state(false);
|
||||||
let descResult = $state<'applied' | 'error' | ''>('');
|
let descResult = $state<'applied' | 'error' | ''>('');
|
||||||
|
let descInstructions = $state('');
|
||||||
|
|
||||||
async function generateDesc() {
|
async function generateDesc() {
|
||||||
const slug = data.book?.slug;
|
const slug = data.book?.slug;
|
||||||
@@ -239,7 +279,7 @@
|
|||||||
const res = await fetch('/api/admin/text-gen/description', {
|
const res = await fetch('/api/admin/text-gen/description', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slug })
|
body: JSON.stringify({ slug, instructions: descInstructions.trim() || undefined })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
@@ -281,9 +321,12 @@
|
|||||||
|
|
||||||
// ── Admin: chapter names generation ───────────────────────────────────────
|
// ── Admin: chapter names generation ───────────────────────────────────────
|
||||||
let chapNamesGenerating = $state(false);
|
let chapNamesGenerating = $state(false);
|
||||||
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string }[]>([]);
|
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string; edited: string }[]>([]);
|
||||||
let chapNamesApplying = $state(false);
|
let chapNamesApplying = $state(false);
|
||||||
let chapNamesResult = $state<'applied' | 'error' | ''>('');
|
let chapNamesResult = $state<'applied' | 'error' | ''>('');
|
||||||
|
let chapNamesPattern = $state('Chapter {n}: {scene}');
|
||||||
|
let chapNamesBatchProgress = $state('');
|
||||||
|
let chapNamesBatchWarnings = $state<string[]>([]);
|
||||||
|
|
||||||
async function generateChapNames() {
|
async function generateChapNames() {
|
||||||
const slug = data.book?.slug;
|
const slug = data.book?.slug;
|
||||||
@@ -291,18 +334,46 @@
|
|||||||
chapNamesGenerating = true;
|
chapNamesGenerating = true;
|
||||||
chapNamesPreview = [];
|
chapNamesPreview = [];
|
||||||
chapNamesResult = '';
|
chapNamesResult = '';
|
||||||
|
chapNamesBatchProgress = '';
|
||||||
|
chapNamesBatchWarnings = [];
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slug, pattern: 'Chapter {n}: {scene}' })
|
body: JSON.stringify({ slug, pattern: chapNamesPattern.trim() || 'Chapter {n}: {scene}' })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (!res.ok) {
|
||||||
const d = await res.json();
|
|
||||||
chapNamesPreview = d.chapters ?? [];
|
|
||||||
} else {
|
|
||||||
chapNamesResult = 'error';
|
chapNamesResult = 'error';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// SSE streaming response
|
||||||
|
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 });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
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; chapters?: { number: number; old_title: string; new_title: string }[]; error?: string; done?: boolean };
|
||||||
|
try { evt = JSON.parse(payload); } catch { continue; }
|
||||||
|
if (evt.done) { chapNamesBatchProgress = `Done — ${evt.total_chapters ?? chapNamesPreview.length} chapters`; chapNamesGenerating = false; break outer; }
|
||||||
|
if (evt.error) {
|
||||||
|
chapNamesBatchWarnings = [...chapNamesBatchWarnings, `Batch ${evt.batch}/${evt.total_batches}: ${evt.error}`];
|
||||||
|
} else if (evt.chapters) {
|
||||||
|
chapNamesPreview = [...chapNamesPreview, ...evt.chapters.map((c) => ({ ...c, edited: c.new_title }))];
|
||||||
|
}
|
||||||
|
if (evt.batch != null && evt.total_batches != null) {
|
||||||
|
chapNamesBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chapNamesPreview.length}/${evt.total_chapters ?? '?'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chapNamesPreview.length === 0 && chapNamesBatchWarnings.length === 0) chapNamesResult = 'error';
|
||||||
} catch {
|
} catch {
|
||||||
chapNamesResult = 'error';
|
chapNamesResult = 'error';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -316,7 +387,7 @@
|
|||||||
chapNamesApplying = true;
|
chapNamesApplying = true;
|
||||||
chapNamesResult = '';
|
chapNamesResult = '';
|
||||||
try {
|
try {
|
||||||
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.new_title }));
|
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.edited?.trim() || c.new_title }));
|
||||||
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
|
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -890,7 +961,32 @@
|
|||||||
|
|
||||||
<!-- Book cover generation -->
|
<!-- Book cover generation -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => (coverPromptOpen = !coverPromptOpen)}
|
||||||
|
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||||
|
>
|
||||||
|
{coverPromptOpen ? 'Hide prompt' : 'Edit prompt'}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/image-gen" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Full editor ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if coverPromptOpen}
|
||||||
|
<textarea
|
||||||
|
bind:value={coverPrompt}
|
||||||
|
rows="3"
|
||||||
|
placeholder={buildCoverPrompt()}
|
||||||
|
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
|
||||||
|
></textarea>
|
||||||
|
{#if book.cover}
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer select-none w-fit">
|
||||||
|
<input type="checkbox" bind:checked={coverUseAsRef} class="accent-(--color-brand)" />
|
||||||
|
<span class="text-xs text-(--color-muted)">Use current cover as reference (img2img)</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onclick={generateCover}
|
onclick={generateCover}
|
||||||
@@ -903,7 +999,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
{/if}
|
{/if}
|
||||||
{m.book_detail_admin_generate()}
|
{m.book_detail_admin_generate()}{coverUseAsRef ? ' (img2img)' : ''}
|
||||||
</button>
|
</button>
|
||||||
{#if coverResult === 'error'}
|
{#if coverResult === 'error'}
|
||||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||||
@@ -932,6 +1028,12 @@
|
|||||||
<!-- Chapter cover generation -->
|
<!-- Chapter cover generation -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_cover()}</p>
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_cover()}</p>
|
||||||
|
<textarea
|
||||||
|
bind:value={chapterCoverPrompt}
|
||||||
|
rows="2"
|
||||||
|
placeholder="Chapter {chapterCoverN} illustration for "{data.book?.title}". Dramatic scene, vivid colors…"
|
||||||
|
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
|
||||||
|
></textarea>
|
||||||
<div class="flex items-end gap-3 flex-wrap">
|
<div class="flex items-end gap-3 flex-wrap">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
|
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
|
||||||
@@ -973,7 +1075,16 @@
|
|||||||
|
|
||||||
<!-- Description generation -->
|
<!-- Description generation -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
|
||||||
|
<a href="/admin/text-gen" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Full editor ↗</a>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={descInstructions}
|
||||||
|
placeholder="e.g. 3-sentence blurb, avoid spoilers, dramatic tone"
|
||||||
|
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onclick={generateDesc}
|
onclick={generateDesc}
|
||||||
@@ -1019,6 +1130,12 @@
|
|||||||
<!-- Chapter names generation -->
|
<!-- Chapter names generation -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_names()}</p>
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_names()}</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={chapNamesPattern}
|
||||||
|
placeholder="Chapter {n}: {scene}"
|
||||||
|
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onclick={generateChapNames}
|
onclick={generateChapNames}
|
||||||
@@ -1033,18 +1150,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{m.book_detail_admin_generate()}
|
{m.book_detail_admin_generate()}
|
||||||
</button>
|
</button>
|
||||||
|
{#if chapNamesBatchProgress && chapNamesGenerating}
|
||||||
|
<span class="text-xs text-(--color-muted)">{chapNamesBatchProgress}</span>
|
||||||
|
{/if}
|
||||||
{#if chapNamesResult === 'error'}
|
{#if chapNamesResult === 'error'}
|
||||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||||
{:else if chapNamesResult === 'applied'}
|
{:else if chapNamesResult === 'applied'}
|
||||||
<span class="text-xs text-green-400">{m.book_detail_admin_applied()} ({chapNamesPreview.length > 0 ? chapNamesPreview.length : ''})</span>
|
<span class="text-xs text-green-400">{m.book_detail_admin_applied()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if chapNamesBatchWarnings.length > 0}
|
||||||
|
{#each chapNamesBatchWarnings as w}
|
||||||
|
<p class="text-xs text-amber-400">{w}</p>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{#if chapNamesPreview.length > 0}
|
{#if chapNamesPreview.length > 0}
|
||||||
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto rounded border border-(--color-border) p-2 bg-(--color-surface-3)">
|
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto rounded border border-(--color-border) p-2 bg-(--color-surface-3)">
|
||||||
{#each chapNamesPreview as ch}
|
{#each chapNamesPreview as ch}
|
||||||
<div class="flex gap-2 text-xs">
|
<div class="flex gap-2 text-xs items-center">
|
||||||
<span class="text-(--color-muted) flex-shrink-0 w-6 text-right">{ch.number}.</span>
|
<span class="text-(--color-muted) flex-shrink-0 w-6 text-right">{ch.number}.</span>
|
||||||
<span class="text-(--color-text) truncate">{ch.new_title}</span>
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={ch.edited}
|
||||||
|
class="flex-1 min-w-0 bg-transparent border-b border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) py-0.5"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -1057,7 +1186,7 @@
|
|||||||
>
|
>
|
||||||
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
|
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => (chapNamesPreview = [])} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
|
<button onclick={() => { chapNamesPreview = []; chapNamesBatchProgress = ''; chapNamesBatchWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user