From 0b82d967982aabf4a08538542a192f82623dad77 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 4 Apr 2026 13:16:10 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20Text=20Gen=20tool=20=E2=80=94?= =?UTF-8?q?=20chapter=20names=20+=20book=20description=20via=20CF=20Worker?= =?UTF-8?q?s=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds backend handlers and SvelteKit UI for an admin text generation tool. The tool lets admins propose and apply AI-generated chapter titles and book descriptions using Cloudflare Workers AI (12 LLM models, model selector shared across both tabs). --- backend/cmd/backend/main.go | 11 + backend/internal/backend/handlers_textgen.go | 413 ++++++++++++++ backend/internal/backend/server.go | 13 + backend/internal/cfai/text.go | 239 ++++++++ ui/messages/en.json | 1 + ui/messages/fr.json | 1 + ui/messages/id.json | 1 + ui/messages/pt.json | 1 + ui/messages/ru.json | 1 + ui/src/lib/paraglide/messages/_index.js | 1 + .../paraglide/messages/admin_nav_text_gen.js | 44 ++ ui/src/routes/admin/+layout.svelte | 3 +- ui/src/routes/admin/text-gen/+page.server.ts | 27 + ui/src/routes/admin/text-gen/+page.svelte | 508 ++++++++++++++++++ .../admin/text-gen/chapter-names/+server.ts | 33 ++ .../text-gen/chapter-names/apply/+server.ts | 32 ++ .../api/admin/text-gen/description/+server.ts | 33 ++ .../text-gen/description/apply/+server.ts | 32 ++ .../api/admin/text-gen/models/+server.ts | 17 + 19 files changed, 1410 insertions(+), 1 deletion(-) create mode 100644 backend/internal/backend/handlers_textgen.go create mode 100644 backend/internal/cfai/text.go create mode 100644 ui/src/lib/paraglide/messages/admin_nav_text_gen.js create mode 100644 ui/src/routes/admin/text-gen/+page.server.ts create mode 100644 ui/src/routes/admin/text-gen/+page.svelte create mode 100644 ui/src/routes/api/admin/text-gen/chapter-names/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/chapter-names/apply/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/description/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/description/apply/+server.ts create mode 100644 ui/src/routes/api/admin/text-gen/models/+server.ts diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 835803b..9f531d2 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -133,6 +133,15 @@ func run() error { log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — image generation unavailable") } + // ── Cloudflare Workers AI Text Generation ───────────────────────────────── + var textGenClient cfai.TextGenClient + if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" { + textGenClient = cfai.NewTextGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken) + log.Info("cloudflare AI text generation enabled") + } else { + log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — text generation unavailable") + } + // ── Meilisearch (search reads only; indexing is the runner's job) ──────── var searchIndex meili.Client if cfg.Meilisearch.URL != "" { @@ -184,6 +193,8 @@ func run() error { PocketTTS: pocketTTSClient, CFAI: cfaiClient, ImageGen: imageGenClient, + TextGen: textGenClient, + BookWriter: store, Log: log, }, ) diff --git a/backend/internal/backend/handlers_textgen.go b/backend/internal/backend/handlers_textgen.go new file mode 100644 index 0000000..2659140 --- /dev/null +++ b/backend/internal/backend/handlers_textgen.go @@ -0,0 +1,413 @@ +package backend + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/libnovel/backend/internal/cfai" + "github.com/libnovel/backend/internal/domain" +) + +// 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) { + if s.deps.TextGen == nil { + jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)") + return + } + models := s.deps.TextGen.Models() + writeJSON(w, 0, map[string]any{"models": models}) +} + +// ── Chapter names ───────────────────────────────────────────────────────────── + +// textGenChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names. +type textGenChapterNamesRequest struct { + // Slug is the book slug whose chapters to process. + Slug string `json:"slug"` + // Pattern is a free-text description of the desired naming convention, + // e.g. "Chapter {n}: {brief scene description}". + Pattern string `json:"pattern"` + // Model is the CF Workers AI model ID. Defaults to the recommended model when empty. + Model string `json:"model"` + // MaxTokens limits response length (0 = model default). + 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"` + // OldTitle is the current title stored in the database. + OldTitle string `json:"old_title"` + // NewTitle is the AI-proposed replacement. + NewTitle string `json:"new_title"` +} + +// 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. +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)") + return + } + + var req textGenChapterNamesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + if strings.TrimSpace(req.Pattern) == "" { + jsonError(w, http.StatusBadRequest, "pattern is required") + return + } + + // Load existing chapter list. + chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error()) + return + } + if len(chapters) == 0 { + jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug)) + return + } + + // Build the prompt. + var chapterListSB strings.Builder + for _, ch := range chapters { + chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title)) + } + + systemPrompt := `You are a chapter title editor for a web novel platform. ` + + `The user will provide a list of chapter numbers with their current titles, ` + + `and a naming pattern. Your task is to produce a renamed version of every chapter ` + + `following the pattern exactly. ` + + `Respond ONLY with a JSON array — no prose, no markdown fences, no explanation. ` + + `Each element must be an object: {"number": , "title": }. ` + + `Output every chapter in the input list. Do not skip any.` + + userPrompt := fmt.Sprintf( + "Naming pattern: %s\n\nChapters:\n%s", + req.Pattern, + chapterListSB.String(), + ) + + model := cfai.TextModel(req.Model) + if model == "" { + model = cfai.DefaultTextModel + } + + s.deps.Log.Info("admin: text-gen chapter-names requested", + "slug", req.Slug, "chapters", len(chapters), "model", model) + + raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{ + Model: model, + Messages: []cfai.TextMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + }, + MaxTokens: req.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 + } + + // Parse the JSON array from the model response. + proposed := parseChapterTitlesJSON(raw) + + // 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, + }) + } + + writeJSON(w, 0, textGenChapterNamesResponse{ + Chapters: result, + Model: string(model), + RawResponse: raw, + }) +} + +// parseChapterTitlesJSON extracts the JSON array from a model response. +// It tolerates markdown fences and surrounding prose. +type rawChapterTitle struct { + Number int `json:"number"` + Title string `json:"title"` +} + +func parseChapterTitlesJSON(raw string) []rawChapterTitle { + // Strip markdown fences if present. + s := raw + if idx := strings.Index(s, "```json"); idx >= 0 { + s = s[idx+7:] + } else if idx := strings.Index(s, "```"); idx >= 0 { + s = s[idx+3:] + } + if idx := strings.LastIndex(s, "```"); idx >= 0 { + s = s[:idx] + } + // Find the JSON array boundaries. + start := strings.Index(s, "[") + end := strings.LastIndex(s, "]") + if start < 0 || end <= start { + return nil + } + s = s[start : end+1] + var out []rawChapterTitle + json.Unmarshal([]byte(s), &out) //nolint:errcheck + return out +} + +// ── Apply chapter names ─────────────────────────────────────────────────────── + +// applyChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names/apply. +type applyChapterNamesRequest struct { + // Slug is the book slug to update. + Slug string `json:"slug"` + // Chapters is the list of chapters to save (number + new_title pairs). + // The UI may modify individual titles before confirming. + Chapters []applyChapterEntry `json:"chapters"` +} + +type applyChapterEntry struct { + Number int `json:"number"` + Title string `json:"title"` +} + +// handleAdminTextGenApplyChapterNames handles POST /api/admin/text-gen/chapter-names/apply. +// +// Persists the confirmed chapter titles to PocketBase chapters_idx. +func (s *Server) handleAdminTextGenApplyChapterNames(w http.ResponseWriter, r *http.Request) { + if s.deps.BookWriter == nil { + jsonError(w, http.StatusServiceUnavailable, "book writer not configured") + return + } + + var req applyChapterNamesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + if len(req.Chapters) == 0 { + jsonError(w, http.StatusBadRequest, "chapters is required") + return + } + + refs := make([]domain.ChapterRef, 0, len(req.Chapters)) + for _, ch := range req.Chapters { + if ch.Number <= 0 { + continue + } + refs = append(refs, domain.ChapterRef{ + Number: ch.Number, + Title: strings.TrimSpace(ch.Title), + }) + } + + if err := s.deps.BookWriter.WriteChapterRefs(r.Context(), req.Slug, refs); err != nil { + s.deps.Log.Error("admin: apply chapter names failed", "slug", req.Slug, "err", err) + jsonError(w, http.StatusInternalServerError, "write chapter refs: "+err.Error()) + return + } + + s.deps.Log.Info("admin: chapter names applied", "slug", req.Slug, "count", len(refs)) + writeJSON(w, 0, map[string]any{"updated": len(refs)}) +} + +// ── Book description ────────────────────────────────────────────────────────── + +// textGenDescriptionRequest is the JSON body for POST /api/admin/text-gen/description. +type textGenDescriptionRequest struct { + // Slug is the book slug whose description to regenerate. + Slug string `json:"slug"` + // Instructions is an optional free-text hint for the AI, + // e.g. "Write a 3-sentence blurb, avoid spoilers, dramatic tone." + Instructions string `json:"instructions"` + // Model is the CF Workers AI model ID. Defaults to recommended when empty. + Model string `json:"model"` + // MaxTokens limits response length (0 = model default). + MaxTokens int `json:"max_tokens"` +} + +// textGenDescriptionResponse is the JSON body returned by POST /api/admin/text-gen/description. +type textGenDescriptionResponse struct { + // OldDescription is the current summary stored in the database. + OldDescription string `json:"old_description"` + // NewDescription is the AI-proposed replacement. + NewDescription string `json:"new_description"` + // Model is the model that was used. + Model string `json:"model"` +} + +// handleAdminTextGenDescription handles POST /api/admin/text-gen/description. +// +// Reads the current book metadata, sends it to the LLM, and returns a proposed +// new description. Does NOT persist anything — the user must confirm via +// POST /api/admin/text-gen/description/apply. +func (s *Server) handleAdminTextGenDescription(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)") + return + } + + var req textGenDescriptionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + + // Load current book metadata. + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + systemPrompt := `You are a book description writer for a web novel platform. ` + + `Given a book's title, author, genres, and current description, write an improved ` + + `description that accurately captures the story. ` + + `Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.` + + instructions := strings.TrimSpace(req.Instructions) + if instructions == "" { + instructions = "Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging." + } + + userPrompt := fmt.Sprintf( + "Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s", + meta.Title, + meta.Author, + strings.Join(meta.Genres, ", "), + meta.Status, + meta.Summary, + instructions, + ) + + model := cfai.TextModel(req.Model) + if model == "" { + model = cfai.DefaultTextModel + } + + s.deps.Log.Info("admin: text-gen description requested", + "slug", req.Slug, "model", model) + + newDesc, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{ + Model: model, + Messages: []cfai.TextMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + }, + MaxTokens: req.MaxTokens, + }) + if genErr != nil { + s.deps.Log.Error("admin: text-gen description failed", "err", genErr) + jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error()) + return + } + + writeJSON(w, 0, textGenDescriptionResponse{ + OldDescription: meta.Summary, + NewDescription: strings.TrimSpace(newDesc), + Model: string(model), + }) +} + +// ── Apply description ───────────────────────────────────────────────────────── + +// applyDescriptionRequest is the JSON body for POST /api/admin/text-gen/description/apply. +type applyDescriptionRequest struct { + // Slug is the book slug to update. + Slug string `json:"slug"` + // Description is the new summary text to persist. + Description string `json:"description"` +} + +// handleAdminTextGenApplyDescription handles POST /api/admin/text-gen/description/apply. +// +// Updates only the summary field in PocketBase, leaving all other book metadata +// unchanged. +func (s *Server) handleAdminTextGenApplyDescription(w http.ResponseWriter, r *http.Request) { + if s.deps.BookWriter == nil { + jsonError(w, http.StatusServiceUnavailable, "book writer not configured") + return + } + + var req applyDescriptionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + if strings.TrimSpace(req.Description) == "" { + jsonError(w, http.StatusBadRequest, "description is required") + return + } + + // Read existing metadata so we can write it back with only summary changed. + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + meta.Summary = strings.TrimSpace(req.Description) + if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil { + s.deps.Log.Error("admin: apply description failed", "slug", req.Slug, "err", err) + jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error()) + return + } + + s.deps.Log.Info("admin: book description applied", "slug", req.Slug) + writeJSON(w, 0, map[string]any{"updated": true}) +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 4fb7f4a..acba23a 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -76,6 +76,12 @@ type Dependencies struct { // ImageGen is the Cloudflare Workers AI image generation client. // If nil, image generation endpoints return 503. ImageGen cfai.ImageGenClient + // TextGen is the Cloudflare Workers AI text generation client. + // If nil, text generation endpoints return 503. + TextGen cfai.TextGenClient + // BookWriter writes book metadata and chapter refs to PocketBase. + // Used by admin text-gen apply endpoints. + BookWriter bookstore.BookWriter // Log is the structured logger. Log *slog.Logger } @@ -191,6 +197,13 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen) mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover) + // Admin text generation endpoints (chapter names + book description) + mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels) + mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames) + mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames) + mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription) + mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription) + // Voices list mux.HandleFunc("GET /api/voices", s.handleVoices) diff --git a/backend/internal/cfai/text.go b/backend/internal/cfai/text.go new file mode 100644 index 0000000..444c1b8 --- /dev/null +++ b/backend/internal/cfai/text.go @@ -0,0 +1,239 @@ +// Text generation via Cloudflare Workers AI LLM models. +// +// API reference: +// +// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model} +// Authorization: Bearer {apiToken} +// Content-Type: application/json +// +// Request body (all models): +// +// { "messages": [{"role":"system","content":"..."},{"role":"user","content":"..."}] } +// +// Response (wrapped): +// +// { "result": { "response": "..." }, "success": true } +package cfai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// TextModel identifies a Cloudflare Workers AI text generation model. +type TextModel string + +const ( + // TextModelGemma4 — Google Gemma 4, 256k context. + TextModelGemma4 TextModel = "@cf/google/gemma-4-26b-a4b-it" + // TextModelLlama4Scout — Meta Llama 4 Scout 17B, multimodal. + TextModelLlama4Scout TextModel = "@cf/meta/llama-4-scout-17b-16e-instruct" + // TextModelLlama33_70B — Meta Llama 3.3 70B, fast fp8. + TextModelLlama33_70B TextModel = "@cf/meta/llama-3.3-70b-instruct-fp8-fast" + // TextModelQwen3_30B — Qwen3 30B MoE, function calling. + TextModelQwen3_30B TextModel = "@cf/qwen/qwen3-30b-a3b-fp8" + // TextModelMistralSmall — Mistral Small 3.1 24B, 128k context. + TextModelMistralSmall TextModel = "@cf/mistralai/mistral-small-3.1-24b-instruct" + // TextModelQwQ32B — Qwen QwQ 32B reasoning model. + TextModelQwQ32B TextModel = "@cf/qwen/qwq-32b" + // TextModelDeepSeekR1 — DeepSeek R1 distill Qwen 32B. + TextModelDeepSeekR1 TextModel = "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b" + // TextModelGemma3_12B — Google Gemma 3 12B, 80k context. + TextModelGemma3_12B TextModel = "@cf/google/gemma-3-12b-it" + // TextModelGPTOSS120B — OpenAI gpt-oss-120b, high reasoning. + TextModelGPTOSS120B TextModel = "@cf/openai/gpt-oss-120b" + // TextModelGPTOSS20B — OpenAI gpt-oss-20b, lower latency. + TextModelGPTOSS20B TextModel = "@cf/openai/gpt-oss-20b" + // TextModelNemotron3 — NVIDIA Nemotron 3 120B, agentic. + TextModelNemotron3 TextModel = "@cf/nvidia/nemotron-3-120b-a12b" + // TextModelLlama32_3B — Meta Llama 3.2 3B, lightweight. + TextModelLlama32_3B TextModel = "@cf/meta/llama-3.2-3b-instruct" + + // DefaultTextModel is the default model used when none is specified. + DefaultTextModel = TextModelLlama4Scout +) + +// TextModelInfo describes a single text generation model. +type TextModelInfo struct { + ID string `json:"id"` + Label string `json:"label"` + Provider string `json:"provider"` + ContextSize int `json:"context_size"` // max context in tokens + Description string `json:"description"` +} + +// AllTextModels returns metadata about every supported text generation model. +func AllTextModels() []TextModelInfo { + return []TextModelInfo{ + { + ID: string(TextModelGemma4), Label: "Gemma 4 26B", Provider: "Google", + ContextSize: 256000, + Description: "Google's most intelligent open model family. 256k context, function calling.", + }, + { + ID: string(TextModelLlama4Scout), Label: "Llama 4 Scout 17B", Provider: "Meta", + ContextSize: 131000, + Description: "Natively multimodal, 16 experts. Good all-purpose model with function calling.", + }, + { + ID: string(TextModelLlama33_70B), Label: "Llama 3.3 70B (fp8 fast)", Provider: "Meta", + ContextSize: 24000, + Description: "Llama 3.3 70B quantized to fp8 for speed. Excellent instruction following.", + }, + { + ID: string(TextModelQwen3_30B), Label: "Qwen3 30B MoE", Provider: "Qwen", + ContextSize: 32768, + Description: "MoE architecture with strong reasoning and instruction following.", + }, + { + ID: string(TextModelMistralSmall), Label: "Mistral Small 3.1 24B", Provider: "MistralAI", + ContextSize: 128000, + Description: "Strong text performance with 128k context and function calling.", + }, + { + ID: string(TextModelQwQ32B), Label: "QwQ 32B (reasoning)", Provider: "Qwen", + ContextSize: 24000, + Description: "Reasoning model — thinks before answering. Slower but more accurate.", + }, + { + ID: string(TextModelDeepSeekR1), Label: "DeepSeek R1 32B", Provider: "DeepSeek", + ContextSize: 80000, + Description: "R1-distilled reasoning model. Outperforms o1-mini on many benchmarks.", + }, + { + ID: string(TextModelGemma3_12B), Label: "Gemma 3 12B", Provider: "Google", + ContextSize: 80000, + Description: "Multimodal, 128k context, multilingual (140+ languages).", + }, + { + ID: string(TextModelGPTOSS120B), Label: "GPT-OSS 120B", Provider: "OpenAI", + ContextSize: 128000, + Description: "OpenAI open-weight model for production, general purpose, high reasoning.", + }, + { + ID: string(TextModelGPTOSS20B), Label: "GPT-OSS 20B", Provider: "OpenAI", + ContextSize: 128000, + Description: "OpenAI open-weight model for lower latency and specialized use cases.", + }, + { + ID: string(TextModelNemotron3), Label: "Nemotron 3 120B", Provider: "NVIDIA", + ContextSize: 256000, + Description: "Hybrid MoE with leading accuracy for multi-agent applications.", + }, + { + ID: string(TextModelLlama32_3B), Label: "Llama 3.2 3B", Provider: "Meta", + ContextSize: 80000, + Description: "Lightweight model for simple tasks. Fast and cheap.", + }, + } +} + +// TextMessage is a single message in a chat conversation. +type TextMessage struct { + Role string `json:"role"` // "system" or "user" + Content string `json:"content"` // message text +} + +// TextRequest is the input to Generate. +type TextRequest struct { + // Model is the CF Workers AI model ID. Defaults to DefaultTextModel when empty. + Model TextModel + // Messages is the conversation history (system + user messages). + Messages []TextMessage + // MaxTokens limits the output length (0 = model default). + MaxTokens int +} + +// TextGenClient generates text via Cloudflare Workers AI LLM models. +type TextGenClient interface { + // Generate sends a chat-style request and returns the model's response text. + Generate(ctx context.Context, req TextRequest) (string, error) + + // Models returns metadata about all supported text generation models. + Models() []TextModelInfo +} + +// textGenHTTPClient is the concrete CF AI text generation client. +type textGenHTTPClient struct { + accountID string + apiToken string + http *http.Client +} + +// NewTextGen returns a TextGenClient for the given Cloudflare account. +func NewTextGen(accountID, apiToken string) TextGenClient { + return &textGenHTTPClient{ + accountID: accountID, + apiToken: apiToken, + http: &http.Client{Timeout: 5 * time.Minute}, + } +} + +// Generate sends messages to the model and returns the response text. +func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (string, error) { + if req.Model == "" { + req.Model = DefaultTextModel + } + + body := map[string]any{ + "messages": req.Messages, + } + if req.MaxTokens > 0 { + body["max_tokens"] = req.MaxTokens + } + + encoded, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("cfai/text: marshal: %w", err) + } + + url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s", + c.accountID, string(req.Model)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded)) + if err != nil { + return "", fmt.Errorf("cfai/text: build request: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+c.apiToken) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(httpReq) + if err != nil { + return "", fmt.Errorf("cfai/text: http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(resp.Body) + msg := string(errBody) + if len(msg) > 300 { + msg = msg[:300] + } + return "", fmt.Errorf("cfai/text: model %s returned %d: %s", req.Model, resp.StatusCode, msg) + } + + // CF AI wraps responses: { "result": { "response": "..." }, "success": true } + var wrapper struct { + Result struct { + Response string `json:"response"` + } `json:"result"` + Success bool `json:"success"` + Errors []string `json:"errors"` + } + if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil { + return "", fmt.Errorf("cfai/text: decode response: %w", err) + } + if !wrapper.Success { + return "", fmt.Errorf("cfai/text: model %s error: %v", req.Model, wrapper.Errors) + } + return wrapper.Result.Response, nil +} + +// Models returns all supported text generation model metadata. +func (c *textGenHTTPClient) Models() []TextModelInfo { + return AllTextModels() +} diff --git a/ui/messages/en.json b/ui/messages/en.json index 54c11f0..2779e02 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -363,6 +363,7 @@ "admin_nav_translation": "Translation", "admin_nav_changelog": "Changelog", "admin_nav_image_gen": "Image Gen", + "admin_nav_text_gen": "Text Gen", "admin_nav_feedback": "Feedback", "admin_nav_errors": "Errors", "admin_nav_analytics": "Analytics", diff --git a/ui/messages/fr.json b/ui/messages/fr.json index 46d0d33..8f48460 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -363,6 +363,7 @@ "admin_nav_translation": "Traduction", "admin_nav_changelog": "Modifications", "admin_nav_image_gen": "Image Gen", + "admin_nav_text_gen": "Text Gen", "admin_nav_feedback": "Retours", "admin_nav_errors": "Erreurs", "admin_nav_analytics": "Analytique", diff --git a/ui/messages/id.json b/ui/messages/id.json index fbee9f0..66e3396 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -363,6 +363,7 @@ "admin_nav_translation": "Terjemahan", "admin_nav_changelog": "Perubahan", "admin_nav_image_gen": "Image Gen", + "admin_nav_text_gen": "Text Gen", "admin_nav_feedback": "Masukan", "admin_nav_errors": "Kesalahan", "admin_nav_analytics": "Analitik", diff --git a/ui/messages/pt.json b/ui/messages/pt.json index 828ec0c..cbde85f 100644 --- a/ui/messages/pt.json +++ b/ui/messages/pt.json @@ -363,6 +363,7 @@ "admin_nav_translation": "Tradução", "admin_nav_changelog": "Alterações", "admin_nav_image_gen": "Image Gen", + "admin_nav_text_gen": "Text Gen", "admin_nav_feedback": "Feedback", "admin_nav_errors": "Erros", "admin_nav_analytics": "Análise", diff --git a/ui/messages/ru.json b/ui/messages/ru.json index 62bf672..f903b23 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -363,6 +363,7 @@ "admin_nav_translation": "Перевод", "admin_nav_changelog": "Изменения", "admin_nav_image_gen": "Image Gen", + "admin_nav_text_gen": "Text Gen", "admin_nav_feedback": "Отзывы", "admin_nav_errors": "Ошибки", "admin_nav_analytics": "Аналитика", diff --git a/ui/src/lib/paraglide/messages/_index.js b/ui/src/lib/paraglide/messages/_index.js index 382d75f..ba38425 100644 --- a/ui/src/lib/paraglide/messages/_index.js +++ b/ui/src/lib/paraglide/messages/_index.js @@ -334,6 +334,7 @@ export * from './admin_nav_audio.js' export * from './admin_nav_translation.js' export * from './admin_nav_changelog.js' export * from './admin_nav_image_gen.js' +export * from './admin_nav_text_gen.js' export * from './admin_nav_feedback.js' export * from './admin_nav_errors.js' export * from './admin_nav_analytics.js' diff --git a/ui/src/lib/paraglide/messages/admin_nav_text_gen.js b/ui/src/lib/paraglide/messages/admin_nav_text_gen.js new file mode 100644 index 0000000..4acc6bd --- /dev/null +++ b/ui/src/lib/paraglide/messages/admin_nav_text_gen.js @@ -0,0 +1,44 @@ +/* eslint-disable */ +import { getLocale, experimentalStaticLocale } from '../runtime.js'; + +/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ + +/** @typedef {{}} Admin_Nav_Text_GenInputs */ + +const en_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Text Gen`) +}; + +const ru_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Text Gen`) +}; + +const id_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Text Gen`) +}; + +const pt_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Text Gen`) +}; + +const fr_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Text Gen`) +}; + +/** +* | output | +* | --- | +* | "Text Gen" | +* +* @param {Admin_Nav_Text_GenInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ +export const admin_nav_text_gen = /** @type {((inputs?: Admin_Nav_Text_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { + const locale = experimentalStaticLocale ?? options.locale ?? getLocale() + if (locale === "en") return en_admin_nav_text_gen(inputs) + if (locale === "ru") return ru_admin_nav_text_gen(inputs) + if (locale === "id") return id_admin_nav_text_gen(inputs) + if (locale === "pt") return pt_admin_nav_text_gen(inputs) + return fr_admin_nav_text_gen(inputs) +}); \ No newline at end of file diff --git a/ui/src/routes/admin/+layout.svelte b/ui/src/routes/admin/+layout.svelte index 4533092..77051b4 100644 --- a/ui/src/routes/admin/+layout.svelte +++ b/ui/src/routes/admin/+layout.svelte @@ -7,7 +7,8 @@ { href: '/admin/audio', label: () => m.admin_nav_audio() }, { href: '/admin/translation', label: () => m.admin_nav_translation() }, { href: '/admin/changelog', label: () => m.admin_nav_changelog() }, - { href: '/admin/image-gen', label: () => m.admin_nav_image_gen() } + { href: '/admin/image-gen', label: () => m.admin_nav_image_gen() }, + { href: '/admin/text-gen', label: () => m.admin_nav_text_gen() } ]; const externalLinks = [ diff --git a/ui/src/routes/admin/text-gen/+page.server.ts b/ui/src/routes/admin/text-gen/+page.server.ts new file mode 100644 index 0000000..8a3459e --- /dev/null +++ b/ui/src/routes/admin/text-gen/+page.server.ts @@ -0,0 +1,27 @@ +import type { PageServerLoad } from './$types'; +import { backendFetch } from '$lib/server/scraper'; +import { log } from '$lib/server/logger'; + +export interface TextModelInfo { + id: string; + label: string; + provider: string; + context_size: number; + description: string; +} + +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[] }; + } +}; diff --git a/ui/src/routes/admin/text-gen/+page.svelte b/ui/src/routes/admin/text-gen/+page.svelte new file mode 100644 index 0000000..eabce6d --- /dev/null +++ b/ui/src/routes/admin/text-gen/+page.svelte @@ -0,0 +1,508 @@ + + + + Text Gen — Admin + + +
+ +
+

Text Generation

+

+ Generate chapter titles and book descriptions using Cloudflare Workers AI. +

+
+ + +
+ + + {#if selectedModelInfo} +

{selectedModelInfo.description}

+ {/if} +
+ + +
+ {#each (['chapters', 'description'] as const) as t} + + {/each} +
+ + + {#if activeTab === 'chapters'} +
+ +
+
+ + +
+ +
+ + +

+ Describe the desired chapter title format. The AI will rename every chapter to match. +

+
+ + + + {#if chError} +

{chError}

+ {/if} +
+ + +
+ {#if chProposals.length > 0} +
+
+

+ {chProposals.length} proposals + {#if chUsedModel} + · {chUsedModel.split('/').pop()} + {/if} +

+ +
+ + {#if chShowRaw} +
{chRawResponse}
+ {/if} + +
+ {#each chProposals as proposal} +
+
+ #{proposal.number} +

+ {proposal.old_title} +

+
+ +
+ {/each} +
+ +
+ + +
+ + {#if chApplyError} +

{chApplyError}

+ {/if} + {#if chApplySuccess} +

+ {chProposals.length} chapter titles saved successfully. +

+ {/if} +
+ {:else if chGenerating} +
+
+ + + + +

Generating chapter titles…

+
+
+ {:else} +
+

Proposed titles will appear here

+
+ {/if} +
+
+ {/if} + + + {#if activeTab === 'description'} +
+ +
+
+ + +
+ +
+ + +
+ + + + {#if dError} +

{dError}

+ {/if} +
+ + +
+ {#if dNewDesc || dOldDesc} +
+ {#if dOldDesc} +
+

Current description

+
+

{dOldDesc}

+
+
+ {/if} + + {#if dNewDesc} +
+
+

+ Proposed description + {#if dUsedModel} + · {dUsedModel.split('/').pop()} + {/if} +

+
+ +
+ +
+ + +
+ + {#if dApplyError} +

{dApplyError}

+ {/if} + {#if dApplySuccess} +

Description saved successfully.

+ {/if} + {/if} +
+ {:else if dGenerating} +
+
+ + + + +

Generating description…

+
+
+ {:else} +
+

Proposed description will appear here

+
+ {/if} +
+
+ {/if} +
diff --git a/ui/src/routes/api/admin/text-gen/chapter-names/+server.ts b/ui/src/routes/api/admin/text-gen/chapter-names/+server.ts new file mode 100644 index 0000000..29a92fa --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/chapter-names/+server.ts @@ -0,0 +1,33 @@ +/** + * 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. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const body = await request.text(); + let res: Response; + try { + res = await backendFetch('/api/admin/text-gen/chapter-names', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/chapter-names', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui/src/routes/api/admin/text-gen/chapter-names/apply/+server.ts b/ui/src/routes/api/admin/text-gen/chapter-names/apply/+server.ts new file mode 100644 index 0000000..a68f3e6 --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/chapter-names/apply/+server.ts @@ -0,0 +1,32 @@ +/** + * POST /api/admin/text-gen/chapter-names/apply + * + * Admin-only proxy to persist confirmed chapter titles to PocketBase. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const body = await request.text(); + let res: Response; + try { + res = await backendFetch('/api/admin/text-gen/chapter-names/apply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/chapter-names/apply', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui/src/routes/api/admin/text-gen/description/+server.ts b/ui/src/routes/api/admin/text-gen/description/+server.ts new file mode 100644 index 0000000..943de5a --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/description/+server.ts @@ -0,0 +1,33 @@ +/** + * POST /api/admin/text-gen/description + * + * Admin-only proxy to the Go backend's book description generation endpoint. + * Returns an AI-proposed description; does NOT persist anything. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const body = await request.text(); + let res: Response; + try { + res = await backendFetch('/api/admin/text-gen/description', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/description', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui/src/routes/api/admin/text-gen/description/apply/+server.ts b/ui/src/routes/api/admin/text-gen/description/apply/+server.ts new file mode 100644 index 0000000..4229eb6 --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/description/apply/+server.ts @@ -0,0 +1,32 @@ +/** + * POST /api/admin/text-gen/description/apply + * + * Admin-only proxy to persist the confirmed book description to PocketBase. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const body = await request.text(); + let res: Response; + try { + res = await backendFetch('/api/admin/text-gen/description/apply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/text-gen/description/apply', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui/src/routes/api/admin/text-gen/models/+server.ts b/ui/src/routes/api/admin/text-gen/models/+server.ts new file mode 100644 index 0000000..8ee1ae3 --- /dev/null +++ b/ui/src/routes/api/admin/text-gen/models/+server.ts @@ -0,0 +1,17 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { backendFetch } from '$lib/server/scraper'; + +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + let res: Response; + try { + res = await backendFetch('/api/admin/text-gen/models'); + } catch { + throw error(502, 'Could not reach backend'); + } + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +};