feat: admin Text Gen tool — chapter names + book description via CF Workers AI
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m55s
Release / Docker / runner (push) Successful in 3m3s
Release / Docker / ui (push) Successful in 2m31s
Release / Gitea Release (push) Successful in 43s

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).
This commit is contained in:
Admin
2026-04-04 13:16:10 +05:00
parent a2dd0681d2
commit 0b82d96798
19 changed files with 1410 additions and 1 deletions

View File

@@ -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,
},
)

View File

@@ -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": <int>, "title": <string>}. ` +
`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 24 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})
}

View File

@@ -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)

View File

@@ -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()
}