Compare commits

...

3 Commits

Author SHA1 Message Date
root
a904ff4e21 fix: update CF AI image gen to use multipart for FLUX.2 models
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m39s
Release / Gitea Release (push) Successful in 30s
Cloudflare Workers AI changed the API for flux-2-dev, flux-2-klein-4b,
and flux-2-klein-9b to require multipart/form-data (instead of JSON) and
now returns {"image":"<base64>"} instead of raw PNG bytes.

- Add requiresMultipart() helper for the three FLUX.2 models
- callImageAPI builds multipart body for those models, JSON for others
- Parse {"image":"<base64>"} JSON response; fall back to raw bytes for legacy models
- Use "steps" field name (not "num_steps") in multipart forms per CF docs
- Book page: capture and display actual backend error message instead of blank 'Error'
2026-04-08 22:28:36 +05:00
root
04e63414a3 fix: restore swipeStartX declaration (newline eaten in prior edit)
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m34s
Release / Gitea Release (push) Successful in 33s
2026-04-08 21:23:23 +05:00
root
bae363893b fix: include generated id in createComment POST body
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Failing after 32s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
The book_comments collection has a custom 'id' field (type: text,
required: true) distinct from PocketBase's system record ID.
Every comment POST was returning 400 validation_required because
the id field was never sent.

Fix: generate a 15-char hex ID via crypto.randomUUID() and include
it in the payload, matching PocketBase's own ID alphabet.
2026-04-08 21:06:23 +05:00
4 changed files with 182 additions and 73 deletions

View File

@@ -4,17 +4,26 @@
//
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
// Authorization: Bearer {apiToken}
// Content-Type: application/json
//
// Text-only request (all models):
// FLUX.2 models (flux-2-dev, flux-2-klein-4b, flux-2-klein-9b):
//
// { "prompt": "...", "num_steps": 20 }
// Content-Type: multipart/form-data
// Fields: prompt, num_steps, width, height, guidance, image_b64 (optional)
// Response: { "image": "<base64 JPEG>" }
//
// Reference-image request:
// - FLUX models: { "prompt": "...", "image_b64": "<base64>" }
// - SD img2img: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
// Other models (flux-1-schnell, SDXL, SD 1.5):
//
// All models return raw PNG bytes on success (Content-Type: image/png).
// Content-Type: application/json
// Body: { "prompt": "...", "num_steps": 20 }
// Response: { "image": "<base64>" } or raw bytes depending on model
//
// Reference-image request (FLUX.2):
//
// Same multipart form; include image_b64 field with base64-encoded reference.
//
// Reference-image request (SD img2img):
//
// JSON body: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
//
// Recommended models for LibNovel:
// - Book covers (no reference): flux-2-dev, flux-2-klein-9b, lucid-origin
@@ -35,7 +44,9 @@ import (
"image/png"
_ "image/png" // register PNG decoder
"io"
"mime/multipart"
"net/http"
"strings"
"time"
)
@@ -173,23 +184,43 @@ func NewImageGen(accountID, apiToken string) ImageGenClient {
}
}
// requiresMultipart reports whether the model requires a multipart/form-data
// request body instead of JSON. FLUX.2 models on Cloudflare Workers AI changed
// their API to require multipart and return {"image":"<base64>"} instead of
// raw image bytes.
func requiresMultipart(model ImageModel) bool {
switch model {
case ImageModelFlux2Dev, ImageModelFlux2Klein4B, ImageModelFlux2Klein9B:
return true
default:
return false
}
}
// GenerateImage generates an image from text only.
func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error) {
req = applyImageDefaults(req)
body := map[string]any{
"prompt": req.Prompt,
"num_steps": req.NumSteps,
// FLUX.2 multipart models use "steps"; JSON models use "num_steps".
stepsKey := "num_steps"
if requiresMultipart(req.Model) {
stepsKey = "steps"
}
fields := map[string]any{
"prompt": req.Prompt,
stepsKey: req.NumSteps,
}
if req.Width > 0 {
body["width"] = req.Width
fields["width"] = req.Width
}
if req.Height > 0 {
body["height"] = req.Height
fields["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
fields["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
return c.callImageAPI(ctx, req.Model, fields, nil)
}
// refImageMaxDim is the maximum dimension (width or height) for reference images
@@ -205,10 +236,37 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req
req = applyImageDefaults(req)
// Shrink the reference image if it exceeds the safe payload size.
// This avoids CF's 4 MB JSON body limit and reduces latency.
refImage = resizeRefImage(refImage, refImageMaxDim)
var body map[string]any
// FLUX.2 multipart models use "steps"; JSON models use "num_steps".
stepsKey := "num_steps"
if requiresMultipart(req.Model) {
stepsKey = "steps"
}
fields := map[string]any{
"prompt": req.Prompt,
stepsKey: req.NumSteps,
}
if req.Width > 0 {
fields["width"] = req.Width
}
if req.Height > 0 {
fields["height"] = req.Height
}
if req.Guidance > 0 {
fields["guidance"] = req.Guidance
}
if requiresMultipart(req.Model) {
// FLUX.2: reference image sent as base64 form field "image_b64".
fields["image_b64"] = base64.StdEncoding.EncodeToString(refImage)
if req.Strength > 0 {
fields["strength"] = req.Strength
}
return c.callImageAPI(ctx, req.Model, fields, nil)
}
if req.Model == ImageModelSD15Img2Img {
pixels, err := decodeImageToRGBA(refImage)
if err != nil {
@@ -218,33 +276,17 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req
if strength <= 0 {
strength = 0.75
}
body = map[string]any{
"prompt": req.Prompt,
"image": pixels,
"strength": strength,
"num_steps": req.NumSteps,
}
} else {
b64 := base64.StdEncoding.EncodeToString(refImage)
body = map[string]any{
"prompt": req.Prompt,
"image_b64": b64,
"num_steps": req.NumSteps,
}
if req.Strength > 0 {
body["strength"] = req.Strength
}
fields["image"] = pixels
fields["strength"] = strength
return c.callImageAPI(ctx, req.Model, fields, nil)
}
if req.Width > 0 {
body["width"] = req.Width
// Other FLUX models: image_b64 JSON field.
fields["image_b64"] = base64.StdEncoding.EncodeToString(refImage)
if req.Strength > 0 {
fields["strength"] = req.Strength
}
if req.Height > 0 {
body["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
return c.callImageAPI(ctx, req.Model, fields, nil)
}
// Models returns all supported image model metadata.
@@ -252,19 +294,56 @@ func (c *imageGenHTTPClient) Models() []ImageModelInfo {
return AllImageModels()
}
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, body map[string]any) ([]byte, error) {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cfai/image: marshal: %w", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, fields map[string]any, _ []byte) ([]byte, error) {
cfURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
c.accountID, string(model))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
var (
bodyReader io.Reader
contentType string
)
if requiresMultipart(model) {
// Build a multipart/form-data body from the fields map.
// All values are serialised to their string representation.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, v := range fields {
var strVal string
switch tv := v.(type) {
case string:
strVal = tv
default:
encoded, merr := json.Marshal(tv)
if merr != nil {
return nil, fmt.Errorf("cfai/image: marshal field %q: %w", k, merr)
}
strVal = strings.Trim(string(encoded), `"`)
}
if werr := mw.WriteField(k, strVal); werr != nil {
return nil, fmt.Errorf("cfai/image: write field %q: %w", k, werr)
}
}
if cerr := mw.Close(); cerr != nil {
return nil, fmt.Errorf("cfai/image: close multipart writer: %w", cerr)
}
bodyReader = &buf
contentType = mw.FormDataContentType()
} else {
encoded, merr := json.Marshal(fields)
if merr != nil {
return nil, fmt.Errorf("cfai/image: marshal: %w", merr)
}
bodyReader = bytes.NewReader(encoded)
contentType = "application/json"
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("cfai/image: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", contentType)
resp, err := c.http.Do(req)
if err != nil {
@@ -272,20 +351,38 @@ func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel,
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai/image: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
msg := string(errBody)
msg := string(respBody)
if len(msg) > 300 {
msg = msg[:300]
}
return nil, fmt.Errorf("cfai/image: model %s returned %d: %s", model, resp.StatusCode, msg)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai/image: read response: %w", err)
// Try to parse as {"image": "<base64>"} first (FLUX.2 and newer models).
// Fall back to treating the body as raw image bytes for legacy models.
var jsonResp struct {
Image string `json:"image"`
}
return data, nil
if jerr := json.Unmarshal(respBody, &jsonResp); jerr == nil && jsonResp.Image != "" {
imgBytes, decErr := base64.StdEncoding.DecodeString(jsonResp.Image)
if decErr != nil {
// Try raw (no padding) base64
imgBytes, decErr = base64.RawStdEncoding.DecodeString(jsonResp.Image)
if decErr != nil {
return nil, fmt.Errorf("cfai/image: decode base64 response: %w", decErr)
}
}
return imgBytes, nil
}
// Legacy: model returned raw image bytes directly.
return respBody, nil
}
func applyImageDefaults(req ImageRequest) ImageRequest {

View File

@@ -1530,6 +1530,7 @@ export async function createComment(
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
id: crypto.randomUUID().replace(/-/g, '').slice(0, 15),
slug,
chapter,
body,

View File

@@ -105,7 +105,8 @@
autoAdvanceSeed++;
}
// ── Swipe handling ─────────────────────────────────────────────────────── let swipeStartX = 0;
// ── Swipe handling ───────────────────────────────────────────────────────
let swipeStartX = 0;
function onSwipeStart(e: TouchEvent) {
swipeStartX = e.touches[0].clientX;
}

View File

@@ -164,6 +164,7 @@
let coverPreview = $state<string | null>(null);
let coverSaving = $state(false);
let coverResult = $state<'saved' | 'error' | ''>('');
let coverErrorMsg = $state('');
let coverPromptOpen = $state(false);
function buildCoverPrompt(): string {
@@ -186,6 +187,7 @@
coverGenerating = true;
coverPreview = null;
coverResult = '';
coverErrorMsg = '';
const promptToUse = coverPrompt.trim() || buildCoverPrompt();
try {
let res: Response;
@@ -210,22 +212,25 @@
} 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) {
const d = await res.json();
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
coverResult = 'error';
}
} catch {
coverResult = 'error';
} finally {
coverGenerating = false;
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
});
}
if (res.ok) {
const d = await res.json();
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
const d = await res.json().catch(() => ({}));
coverErrorMsg = (d as any).error ?? '';
coverResult = 'error';
}
} catch (e: any) {
coverErrorMsg = e?.message ?? '';
coverResult = 'error';
} finally {
coverGenerating = false;
}
}
async function saveCover() {
const slug = data.book?.slug;
@@ -258,6 +263,7 @@
let chapterCoverGenerating = $state(false);
let chapterCoverPreview = $state<string | null>(null);
let chapterCoverResult = $state<'saved' | 'error' | ''>('');
let chapterCoverErrorMsg = $state('');
let chapterCoverSaving = $state(false);
let chapterCoverPrompt = $state('');
@@ -269,6 +275,7 @@
chapterCoverGenerating = true;
chapterCoverPreview = null;
chapterCoverResult = '';
chapterCoverErrorMsg = '';
const promptToUse = chapterCoverPrompt.trim() || `Chapter ${n} illustration for "${data.book?.title ?? slug}". Dramatic scene, vivid colors, detailed art, cinematic lighting.`;
try {
const res = await fetch('/api/admin/image-gen', {
@@ -280,9 +287,12 @@
const d = await res.json();
chapterCoverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
const d = await res.json().catch(() => ({}));
chapterCoverErrorMsg = (d as any).error ?? '';
chapterCoverResult = 'error';
}
} catch {
} catch (e: any) {
chapterCoverErrorMsg = e?.message ?? '';
chapterCoverResult = 'error';
} finally {
chapterCoverGenerating = false;
@@ -1101,7 +1111,7 @@
{m.book_detail_admin_generate()}{coverUseAsRef ? ' (img2img)' : ''}
</button>
{#if coverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
<span class="text-xs text-(--color-danger)">{coverErrorMsg || m.common_error()}</span>
{:else if coverResult === 'saved'}
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
{/if}
@@ -1159,7 +1169,7 @@
{m.book_detail_admin_generate()}
</button>
{#if chapterCoverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
<span class="text-xs text-(--color-danger)">{chapterCoverErrorMsg || m.common_error()}</span>
{:else if chapterCoverResult === 'saved'}
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
{/if}