Spaces:
Running
Running
Version 5 Gradio
Browse files- .gitignore +2 -1
- Makefile +74 -0
- package-lock.json +15 -1
- package.json +2 -1
- src/app/server/actions/generateImage.ts +166 -77
- src/app/server/actions/generateStoryLines.ts +182 -36
- tests/test_connections/test_endpoints.mjs +282 -0
- tests/test_connections/test_endpoints.sh +204 -0
.gitignore
CHANGED
|
@@ -34,4 +34,5 @@ yarn-error.log*
|
|
| 34 |
*.tsbuildinfo
|
| 35 |
next-env.d.ts
|
| 36 |
|
| 37 |
-
/sandbox/
|
|
|
|
|
|
| 34 |
*.tsbuildinfo
|
| 35 |
next-env.d.ts
|
| 36 |
|
| 37 |
+
/sandbox/
|
| 38 |
+
.env
|
Makefile
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Makefile for ai-story-teller (local dev & endpoint checks)
|
| 2 |
+
|
| 3 |
+
# Default Node version (override with: make NODE=20)
|
| 4 |
+
NODE ?= 18
|
| 5 |
+
PORT ?= 3000
|
| 6 |
+
|
| 7 |
+
# Path to .env (create from .env.example or manually)
|
| 8 |
+
ENV ?= .env
|
| 9 |
+
|
| 10 |
+
# Test prompts (override with: make test STORY_PROMPT="..." VOICE="..." IMG_PROMPT="...")
|
| 11 |
+
STORY_PROMPT ?= A short wholesome bedtime story about a friendly dragon.
|
| 12 |
+
VOICE ?= Cloée
|
| 13 |
+
IMG_PROMPT ?= a cute cat sticker
|
| 14 |
+
|
| 15 |
+
.PHONY: help setup env dev build start check-endpoints test clean verify-scripts
|
| 16 |
+
|
| 17 |
+
help:
|
| 18 |
+
@echo "Targets:"
|
| 19 |
+
@echo " setup Install Node deps (npm ci if lockfile exists)"
|
| 20 |
+
@echo " env Create .env from .env.example if missing and show key vars"
|
| 21 |
+
@echo " dev Run Next.js dev server"
|
| 22 |
+
@echo " build Build Next.js app"
|
| 23 |
+
@echo " start Start built Next.js app"
|
| 24 |
+
@echo " check-endpoints Run Node endpoint check (tests/test_connections/test_endpoints.mjs)"
|
| 25 |
+
@echo " test Run bash endpoint checks (story + image)"
|
| 26 |
+
@echo " clean Remove .next"
|
| 27 |
+
@echo ""
|
| 28 |
+
@echo "Vars: NODE=$(NODE) PORT=$(PORT) ENV=$(ENV)"
|
| 29 |
+
@echo "Test Vars: STORY_PROMPT='$(STORY_PROMPT)' VOICE='$(VOICE)' IMG_PROMPT='$(IMG_PROMPT)'"
|
| 30 |
+
|
| 31 |
+
setup:
|
| 32 |
+
@echo ">>> Installing dependencies…"
|
| 33 |
+
@if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
| 34 |
+
|
| 35 |
+
env:
|
| 36 |
+
@if [ ! -f $(ENV) ] && [ -f .env.example ]; then cp .env.example $(ENV); fi
|
| 37 |
+
@if [ ! -f $(ENV) ]; then \
|
| 38 |
+
printf "AI_STORY_API_GRADIO_URL=\"\"\nAI_STORY_API_SECRET_TOKEN=\"secret\"\nAI_FAST_IMAGE_SERVER_API_GRADIO_URL=\"\"\nAI_FAST_IMAGE_SERVER_API_SECRET_TOKEN=\"default_secret\"\nNEXT_PUBLIC_ENABLE_COMMUNITY_SHARING=\"false\"\n" > $(ENV); \
|
| 39 |
+
fi
|
| 40 |
+
@echo ">>> Using $(ENV)"
|
| 41 |
+
@grep -E 'AI_.*|NEXT_PUBLIC_' $(ENV) || true
|
| 42 |
+
|
| 43 |
+
dev: env
|
| 44 |
+
@echo ">>> Starting Next dev on :$(PORT)"
|
| 45 |
+
npm run dev -- -p $(PORT)
|
| 46 |
+
|
| 47 |
+
build: env
|
| 48 |
+
npm run build
|
| 49 |
+
|
| 50 |
+
start: env
|
| 51 |
+
npm run start -- -p $(PORT)
|
| 52 |
+
|
| 53 |
+
# Ensure the bash test script exists & is executable (used by `make test`)
|
| 54 |
+
verify-scripts:
|
| 55 |
+
@test -f tests/test_connections/test_endpoints.sh || { \
|
| 56 |
+
echo "tests/test_connections/test_endpoints.sh not found."; \
|
| 57 |
+
echo "Please create it and make it executable:"; \
|
| 58 |
+
echo " chmod +x tests/test_connections/test_endpoints.sh"; \
|
| 59 |
+
exit 1; \
|
| 60 |
+
}
|
| 61 |
+
@chmod +x tests/test_connections/test_endpoints.sh
|
| 62 |
+
|
| 63 |
+
# NEW: Node-based check (mjs only)
|
| 64 |
+
check-endpoints: env
|
| 65 |
+
@echo ">>> Running Node endpoint check (mjs)…"
|
| 66 |
+
node tests/test_connections/test_endpoints.mjs
|
| 67 |
+
|
| 68 |
+
# Keep `make test` EXACTLY as-is (bash script)
|
| 69 |
+
test: env verify-scripts
|
| 70 |
+
@echo ">>> Running endpoint checks (story + image)…"
|
| 71 |
+
@./tests/test_connections/test_endpoints.sh "$(STORY_PROMPT)" "$(VOICE)" "$(IMG_PROMPT)"
|
| 72 |
+
|
| 73 |
+
clean:
|
| 74 |
+
rm -rf .next
|
package-lock.json
CHANGED
|
@@ -69,7 +69,8 @@
|
|
| 69 |
"@types/qs": "^6.9.7",
|
| 70 |
"@types/react-virtualized": "^9.21.22",
|
| 71 |
"@types/sbd": "^1.0.3",
|
| 72 |
-
"daisyui": "^3.7.4"
|
|
|
|
| 73 |
}
|
| 74 |
},
|
| 75 |
"node_modules/@aashutoshrathi/word-wrap": {
|
|
@@ -3099,6 +3100,19 @@
|
|
| 3099 |
"url": "https://github.com/fb55/domutils?sponsor=1"
|
| 3100 |
}
|
| 3101 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3102 |
"node_modules/electron-to-chromium": {
|
| 3103 |
"version": "1.4.589",
|
| 3104 |
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.589.tgz",
|
|
|
|
| 69 |
"@types/qs": "^6.9.7",
|
| 70 |
"@types/react-virtualized": "^9.21.22",
|
| 71 |
"@types/sbd": "^1.0.3",
|
| 72 |
+
"daisyui": "^3.7.4",
|
| 73 |
+
"dotenv": "^17.2.2"
|
| 74 |
}
|
| 75 |
},
|
| 76 |
"node_modules/@aashutoshrathi/word-wrap": {
|
|
|
|
| 3100 |
"url": "https://github.com/fb55/domutils?sponsor=1"
|
| 3101 |
}
|
| 3102 |
},
|
| 3103 |
+
"node_modules/dotenv": {
|
| 3104 |
+
"version": "17.2.2",
|
| 3105 |
+
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
|
| 3106 |
+
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
|
| 3107 |
+
"dev": true,
|
| 3108 |
+
"license": "BSD-2-Clause",
|
| 3109 |
+
"engines": {
|
| 3110 |
+
"node": ">=12"
|
| 3111 |
+
},
|
| 3112 |
+
"funding": {
|
| 3113 |
+
"url": "https://dotenvx.com"
|
| 3114 |
+
}
|
| 3115 |
+
},
|
| 3116 |
"node_modules/electron-to-chromium": {
|
| 3117 |
"version": "1.4.589",
|
| 3118 |
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.589.tgz",
|
package.json
CHANGED
|
@@ -70,6 +70,7 @@
|
|
| 70 |
"@types/qs": "^6.9.7",
|
| 71 |
"@types/react-virtualized": "^9.21.22",
|
| 72 |
"@types/sbd": "^1.0.3",
|
| 73 |
-
"daisyui": "^3.7.4"
|
|
|
|
| 74 |
}
|
| 75 |
}
|
|
|
|
| 70 |
"@types/qs": "^6.9.7",
|
| 71 |
"@types/react-virtualized": "^9.21.22",
|
| 72 |
"@types/sbd": "^1.0.3",
|
| 73 |
+
"daisyui": "^3.7.4",
|
| 74 |
+
"dotenv": "^17.2.2"
|
| 75 |
}
|
| 76 |
}
|
src/app/server/actions/generateImage.ts
CHANGED
|
@@ -1,18 +1,147 @@
|
|
| 1 |
-
"use server"
|
| 2 |
-
|
| 3 |
-
// TODO add a system to mark failed instances as "unavailable" for a couple of minutes
|
| 4 |
-
// console.log("process.env:", process.env)
|
| 5 |
|
|
|
|
| 6 |
import { generateSeed } from "@/lib/generateSeed";
|
| 7 |
import { getValidNumber } from "@/lib/getValidNumber";
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
export async function generateImage(options: {
|
| 18 |
positivePrompt: string;
|
|
@@ -22,85 +151,45 @@ export async function generateImage(options: {
|
|
| 22 |
height?: number;
|
| 23 |
nbSteps?: number;
|
| 24 |
}): Promise<string> {
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
if (!positivePrompt) {
|
| 29 |
-
throw new Error("missing prompt")
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
// the negative prompt CAN be missing, since we use a trick
|
| 33 |
-
// where we make the interface mandatory in the TS doc,
|
| 34 |
-
// but browsers might send something partial
|
| 35 |
-
const negativePrompt = options?.negativePrompt || ""
|
| 36 |
-
|
| 37 |
-
// we treat 0 as meaning "random seed"
|
| 38 |
-
const seed = (options?.seed ? options.seed : 0) || generateSeed()
|
| 39 |
|
| 40 |
-
const
|
| 41 |
-
const
|
| 42 |
-
const
|
| 43 |
-
|
|
|
|
| 44 |
|
| 45 |
const positive = [
|
| 46 |
-
|
| 47 |
-
// oh well.. is it too late to move this to the bottom?
|
| 48 |
"beautiful",
|
| 49 |
-
|
| 50 |
-
// too opinionated, so let's remove it
|
| 51 |
-
// "intricate details",
|
| 52 |
-
|
| 53 |
positivePrompt,
|
| 54 |
-
|
| 55 |
"award winning",
|
| 56 |
-
"high resolution"
|
| 57 |
-
].filter(
|
| 58 |
-
.join(", ")
|
| 59 |
|
| 60 |
-
const negative =
|
| 61 |
negativePrompt,
|
| 62 |
"watermark",
|
| 63 |
"copyright",
|
| 64 |
"blurry",
|
| 65 |
-
// "artificial",
|
| 66 |
-
// "cropped",
|
| 67 |
"low quality",
|
| 68 |
-
"ugly"
|
| 69 |
-
].filter(
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
const
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
data: [
|
| 81 |
-
positive, // string in 'Prompt' Textbox component
|
| 82 |
-
negative, // string in 'Negative prompt' Textbox component
|
| 83 |
-
seed, // number (numeric value between 0 and 2147483647) in 'Seed' Slider component
|
| 84 |
-
width, // number (numeric value between 256 and 1024) in 'Width' Slider component
|
| 85 |
-
height, // number (numeric value between 256 and 1024) in 'Height' Slider component
|
| 86 |
-
0.0, // can be disabled for LCM SDXL
|
| 87 |
-
nbSteps, // number (numeric value between 2 and 8) in 'Number of inference steps for base' Slider component
|
| 88 |
-
secretToken
|
| 89 |
-
]
|
| 90 |
-
}),
|
| 91 |
-
cache: "no-store",
|
| 92 |
-
})
|
| 93 |
-
|
| 94 |
-
const { data } = await res.json()
|
| 95 |
-
|
| 96 |
-
if (res.status !== 200 || !Array.isArray(data)) {
|
| 97 |
-
// This will activate the closest `error.js` Error Boundary
|
| 98 |
-
throw new Error(`Failed to fetch data (status: ${res.status})`)
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
if (!data[0]) {
|
| 102 |
-
throw new Error(`the returned image was empty`)
|
| 103 |
}
|
| 104 |
|
| 105 |
-
|
| 106 |
-
}
|
|
|
|
|
|
|
|
|
| 1 |
+
"use server";
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
import "server-only";
|
| 4 |
import { generateSeed } from "@/lib/generateSeed";
|
| 5 |
import { getValidNumber } from "@/lib/getValidNumber";
|
| 6 |
|
| 7 |
+
const BASE = (process.env.AI_FAST_IMAGE_SERVER_API_GRADIO_URL || "").replace(/\/+$/, "");
|
| 8 |
+
const SECRET = process.env.AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN || "";
|
| 9 |
+
const DEBUG = (process.env.DEBUG_IMAGE_API || "").toLowerCase() === "true";
|
| 10 |
+
|
| 11 |
+
function assertEnv() {
|
| 12 |
+
if (!BASE) throw new Error("Missing AI_FAST_IMAGE_SERVER_API_GRADIO_URL");
|
| 13 |
+
if (!SECRET) throw new Error("Missing AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function abbrev(s: unknown, n = 1200) {
|
| 17 |
+
const t = typeof s === "string" ? s : JSON.stringify(s);
|
| 18 |
+
return t && t.length > n ? t.slice(0, n) + "…" : t;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
|
| 22 |
+
return Promise.race<T>([
|
| 23 |
+
p,
|
| 24 |
+
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)) as Promise<T>,
|
| 25 |
+
]);
|
| 26 |
+
}
|
| 27 |
+
async function timedFetch(url: string, init?: RequestInit) {
|
| 28 |
+
const t0 = Date.now();
|
| 29 |
+
const res = await withTimeout(fetch(url, init));
|
| 30 |
+
return { res, ms: Date.now() - t0 };
|
| 31 |
+
}
|
| 32 |
+
function stripToJson(text: string) {
|
| 33 |
+
if (!text) return "";
|
| 34 |
+
const iBrace = text.indexOf("{");
|
| 35 |
+
const iBrack = text.indexOf("[");
|
| 36 |
+
const i = [iBrace === -1 ? 1e9 : iBrace, iBrack === -1 ? 1e9 : iBrack].reduce((a, b) => Math.min(a, b), 1e9);
|
| 37 |
+
return i === 1e9 ? text : text.slice(i);
|
| 38 |
+
}
|
| 39 |
+
async function readSSEJson(res: Response, maxMs = 120_000): Promise<any | null> {
|
| 40 |
+
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
| 41 |
+
if (ct.includes("application/json")) {
|
| 42 |
+
const txt = await res.text();
|
| 43 |
+
try { return JSON.parse(stripToJson(txt)); } catch { return null; }
|
| 44 |
+
}
|
| 45 |
+
const reader = res.body?.getReader();
|
| 46 |
+
if (!reader) return null;
|
| 47 |
+
const decoder = new TextDecoder();
|
| 48 |
+
let buf = "";
|
| 49 |
+
let lastJson: any | null = null;
|
| 50 |
+
const start = Date.now();
|
| 51 |
+
|
| 52 |
+
const flush = (chunk: string) => {
|
| 53 |
+
buf += chunk;
|
| 54 |
+
let m: RegExpMatchArray | null;
|
| 55 |
+
while ((m = buf.match(/([\s\S]*?)\r?\n\r?\n/)) !== null) {
|
| 56 |
+
const block = m[1];
|
| 57 |
+
buf = buf.slice(m[0].length);
|
| 58 |
+
const lines = block.split(/\r?\n/);
|
| 59 |
+
const dataLines: string[] = [];
|
| 60 |
+
for (const line of lines) {
|
| 61 |
+
if (!line) continue;
|
| 62 |
+
const idx = line.indexOf(":");
|
| 63 |
+
const field = idx === -1 ? line.trim() : line.slice(0, idx).trim();
|
| 64 |
+
const value = idx === -1 ? "" : line.slice(idx + 1).replace(/^\s/, "");
|
| 65 |
+
if (DEBUG && line.trim()) console.log("[SSE]", line);
|
| 66 |
+
if (field === "data") dataLines.push(value);
|
| 67 |
+
}
|
| 68 |
+
if (dataLines.length) {
|
| 69 |
+
const payload = dataLines.join("\n");
|
| 70 |
+
try {
|
| 71 |
+
lastJson = JSON.parse(stripToJson(payload));
|
| 72 |
+
} catch { /* ignore */ }
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
while (true) {
|
| 78 |
+
if (Date.now() - start > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
|
| 79 |
+
const { value, done } = await reader.read();
|
| 80 |
+
if (done) break;
|
| 81 |
+
flush(decoder.decode(value, { stream: true }));
|
| 82 |
+
}
|
| 83 |
+
return lastJson;
|
| 84 |
+
}
|
| 85 |
|
| 86 |
+
async function gradioCallV5(base: string, fnName: string, dataArray: any[]) {
|
| 87 |
+
const callUrl = `${base}/gradio_api/call/${fnName}`;
|
| 88 |
+
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 89 |
+
method: "POST",
|
| 90 |
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
| 91 |
+
body: JSON.stringify({ data: dataArray }),
|
| 92 |
+
cache: "no-store",
|
| 93 |
+
});
|
| 94 |
+
const postTxt = await postRes.text();
|
| 95 |
+
const postJson = (() => { try { return JSON.parse(stripToJson(postTxt)); } catch { return null; } })();
|
| 96 |
+
if (!postRes.ok) {
|
| 97 |
+
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postTxt ?? "(empty body)";
|
| 98 |
+
throw new Error(`POST ${callUrl} → ${postRes.status} in ${postMs}ms — ${abbrev(detail)}`);
|
| 99 |
+
}
|
| 100 |
+
const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
|
| 101 |
+
if (!eventId) throw new Error(`POST ${callUrl} returned no event_id`);
|
| 102 |
+
|
| 103 |
+
const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
|
| 104 |
+
const { res: getRes, ms: getMs } = await timedFetch(getUrl, { method: "GET", headers: { Accept: "text/event-stream" }});
|
| 105 |
+
if (getRes.status !== 200) {
|
| 106 |
+
const txt = await getRes.text();
|
| 107 |
+
throw new Error(`GET ${getUrl} → ${getRes.status} in ${getMs}ms — ${abbrev(txt)}`);
|
| 108 |
+
}
|
| 109 |
+
const json = await readSSEJson(getRes);
|
| 110 |
+
if (json == null) throw new Error(`GET ${getUrl} returned no payload (SSE error/null).`);
|
| 111 |
+
return json;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
async function gradioPredictLegacy(base: string, body: any) {
|
| 115 |
+
const endpoints = [`${base}/api/predict`, `${base}/run/predict`];
|
| 116 |
+
for (const url of endpoints) {
|
| 117 |
+
try {
|
| 118 |
+
const { res } = await timedFetch(url, {
|
| 119 |
+
method: "POST",
|
| 120 |
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
| 121 |
+
body: JSON.stringify(body),
|
| 122 |
+
cache: "no-store",
|
| 123 |
+
});
|
| 124 |
+
const txt = await res.text();
|
| 125 |
+
const json = (() => { try { return JSON.parse(stripToJson(txt)); } catch { return null; } })();
|
| 126 |
+
if (res.ok) return json;
|
| 127 |
+
} catch {}
|
| 128 |
+
}
|
| 129 |
+
throw new Error("Legacy /predict endpoints unavailable.");
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/** Normalize image payload to a single URL string (or data URI) */
|
| 133 |
+
function extractImageUrl(payload: any): string {
|
| 134 |
+
// v5 often returns an array of FileData objects: [{ url, ... }]
|
| 135 |
+
let d = payload?.data ?? payload;
|
| 136 |
+
if (Array.isArray(d)) {
|
| 137 |
+
const first = d[0];
|
| 138 |
+
if (!first) throw new Error("Image response was empty.");
|
| 139 |
+
if (typeof first === "string") return first;
|
| 140 |
+
if (first?.url) return String(first.url);
|
| 141 |
+
}
|
| 142 |
+
if (d?.url) return String(d.url);
|
| 143 |
+
throw new Error(`Unexpected image response: ${abbrev(payload)}`);
|
| 144 |
+
}
|
| 145 |
|
| 146 |
export async function generateImage(options: {
|
| 147 |
positivePrompt: string;
|
|
|
|
| 151 |
height?: number;
|
| 152 |
nbSteps?: number;
|
| 153 |
}): Promise<string> {
|
| 154 |
+
assertEnv();
|
| 155 |
|
| 156 |
+
const positivePrompt = options?.positivePrompt || "";
|
| 157 |
+
if (!positivePrompt) throw new Error("missing prompt");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
const negativePrompt = options?.negativePrompt || "";
|
| 160 |
+
const seed = (options?.seed ? options.seed : 0) || generateSeed();
|
| 161 |
+
const width = getValidNumber(options?.width, 256, 1024, 512);
|
| 162 |
+
const height = getValidNumber(options?.height, 256, 1024, 512);
|
| 163 |
+
const nbSteps = getValidNumber(options?.nbSteps, 1, 12, 4); // server clamps to <=12 anyway
|
| 164 |
|
| 165 |
const positive = [
|
|
|
|
|
|
|
| 166 |
"beautiful",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
positivePrompt,
|
|
|
|
| 168 |
"award winning",
|
| 169 |
+
"high resolution",
|
| 170 |
+
].filter(Boolean).join(", ");
|
|
|
|
| 171 |
|
| 172 |
+
const negative = [
|
| 173 |
negativePrompt,
|
| 174 |
"watermark",
|
| 175 |
"copyright",
|
| 176 |
"blurry",
|
|
|
|
|
|
|
| 177 |
"low quality",
|
| 178 |
+
"ugly",
|
| 179 |
+
].filter(Boolean).join(", ");
|
| 180 |
+
|
| 181 |
+
// Order must match the Space: [prompt, negative, seed, width, height, guidance, steps, secret_token]
|
| 182 |
+
const dataArray = [positive, negative, seed, width, height, 0.0, nbSteps, SECRET];
|
| 183 |
+
|
| 184 |
+
// 1) Gradio v5
|
| 185 |
+
try {
|
| 186 |
+
const json = await gradioCallV5(BASE, "generate", dataArray);
|
| 187 |
+
return extractImageUrl(json);
|
| 188 |
+
} catch (e) {
|
| 189 |
+
console.warn("Gradio v5 image call failed:", (e as any)?.message || e);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
}
|
| 191 |
|
| 192 |
+
// 2) Legacy fallback (if enabled)
|
| 193 |
+
const legacy = await gradioPredictLegacy(BASE, { fn_index: 0, data: dataArray });
|
| 194 |
+
return extractImageUrl(legacy);
|
| 195 |
+
}
|
src/app/server/actions/generateStoryLines.ts
CHANGED
|
@@ -1,51 +1,197 @@
|
|
| 1 |
-
"use server"
|
| 2 |
|
| 3 |
-
import
|
|
|
|
| 4 |
|
| 5 |
-
const
|
| 6 |
-
const
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
if (!
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
method: "POST",
|
| 20 |
-
headers: {
|
| 21 |
-
|
| 22 |
-
// Authorization: `Bearer ${token}`,
|
| 23 |
-
},
|
| 24 |
-
body: JSON.stringify({
|
| 25 |
-
fn_index: 0, // <- important!
|
| 26 |
-
data: [
|
| 27 |
-
secretToken,
|
| 28 |
-
prompt,
|
| 29 |
-
voice,
|
| 30 |
-
],
|
| 31 |
-
}),
|
| 32 |
cache: "no-store",
|
| 33 |
-
|
| 34 |
-
// next: { revalidate: 1 }
|
| 35 |
-
})
|
| 36 |
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
}
|
|
|
|
| 1 |
+
"use server";
|
| 2 |
|
| 3 |
+
import "server-only";
|
| 4 |
+
import type { StoryLine, TTSVoice } from "@/types";
|
| 5 |
|
| 6 |
+
const BASE = (process.env.AI_STORY_API_GRADIO_URL || "").replace(/\/+$/, "");
|
| 7 |
+
const SECRET = process.env.AI_STORY_API_SECRET_TOKEN || "";
|
| 8 |
+
const LEGACY_FN_INDEX = Number(process.env.AI_STORY_API_FN_INDEX ?? 0); // not used on v5; fallback only
|
| 9 |
+
const DEBUG = (process.env.DEBUG_STORY_API || "").toLowerCase() === "true";
|
| 10 |
|
| 11 |
+
function assertEnv() {
|
| 12 |
+
if (!BASE) throw new Error("Missing AI_STORY_API_GRADIO_URL");
|
| 13 |
+
if (!SECRET) throw new Error("Missing AI_STORY_API_SECRET_TOKEN");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function abbrev(s: unknown, n = 1200) {
|
| 17 |
+
const t = typeof s === "string" ? s : JSON.stringify(s);
|
| 18 |
+
return t && t.length > n ? t.slice(0, n) + "…" : t;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
|
| 22 |
+
return Promise.race<T>([
|
| 23 |
+
p,
|
| 24 |
+
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)) as Promise<T>,
|
| 25 |
+
]);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
async function timedFetch(url: string, init?: RequestInit) {
|
| 29 |
+
const t0 = Date.now();
|
| 30 |
+
const res = await withTimeout(fetch(url, init));
|
| 31 |
+
return { res, ms: Date.now() - t0 };
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function stripToJson(text: string) {
|
| 35 |
+
if (!text) return "";
|
| 36 |
+
const iBrace = text.indexOf("{");
|
| 37 |
+
const iBrack = text.indexOf("[");
|
| 38 |
+
const i = [iBrace === -1 ? 1e9 : iBrace, iBrack === -1 ? 1e9 : iBrack].reduce((a, b) => Math.min(a, b), 1e9);
|
| 39 |
+
return i === 1e9 ? text : text.slice(i);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/** Read a Gradio v5 SSE stream and return the last JSON payload sent in `data:` lines. */
|
| 43 |
+
async function readSSEJson(res: Response, maxMs = 120_000): Promise<any | null> {
|
| 44 |
+
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
| 45 |
+
if (ct.includes("application/json")) {
|
| 46 |
+
const txt = await res.text();
|
| 47 |
+
try { return JSON.parse(stripToJson(txt)); } catch { return null; }
|
| 48 |
}
|
| 49 |
|
| 50 |
+
// Parse text/event-stream
|
| 51 |
+
const reader = res.body?.getReader();
|
| 52 |
+
if (!reader) return null;
|
| 53 |
|
| 54 |
+
const decoder = new TextDecoder();
|
| 55 |
+
let buf = "";
|
| 56 |
+
let lastJson: any | null = null;
|
| 57 |
+
const start = Date.now();
|
| 58 |
|
| 59 |
+
const flush = (chunk: string) => {
|
| 60 |
+
buf += chunk;
|
| 61 |
+
// split events on blank line
|
| 62 |
+
let m: RegExpMatchArray | null;
|
| 63 |
+
while ((m = buf.match(/([\s\S]*?)\r?\n\r?\n/)) !== null) {
|
| 64 |
+
const eventBlock = m[1];
|
| 65 |
+
buf = buf.slice(m[0].length);
|
| 66 |
+
const lines = eventBlock.split(/\r?\n/);
|
| 67 |
+
const dataLines: string[] = [];
|
| 68 |
+
for (const line of lines) {
|
| 69 |
+
if (!line) continue;
|
| 70 |
+
const idx = line.indexOf(":");
|
| 71 |
+
const field = idx === -1 ? line.trim() : line.slice(0, idx).trim();
|
| 72 |
+
const value = idx === -1 ? "" : line.slice(idx + 1).replace(/^\s/, "");
|
| 73 |
+
if (DEBUG && line.trim()) console.log("[SSE]", line);
|
| 74 |
+
if (field === "data") dataLines.push(value);
|
| 75 |
+
}
|
| 76 |
+
if (dataLines.length) {
|
| 77 |
+
const payload = dataLines.join("\n");
|
| 78 |
+
try {
|
| 79 |
+
const maybe = JSON.parse(stripToJson(payload));
|
| 80 |
+
lastJson = maybe;
|
| 81 |
+
} catch {
|
| 82 |
+
/* ignore non-JSON frames */
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
while (true) {
|
| 89 |
+
if (Date.now() - start > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
|
| 90 |
+
const { value, done } = await reader.read();
|
| 91 |
+
if (done) break;
|
| 92 |
+
flush(decoder.decode(value, { stream: true }));
|
| 93 |
+
}
|
| 94 |
+
return lastJson;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/** v5 call API: POST /gradio_api/call/<fn> → {event_id} → GET /gradio_api/call/<fn>/<event_id> (SSE). */
|
| 98 |
+
async function gradioCallV5(base: string, fnName: string, dataArray: any[]) {
|
| 99 |
+
const callUrl = `${base}/gradio_api/call/${fnName}`;
|
| 100 |
+
|
| 101 |
+
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 102 |
method: "POST",
|
| 103 |
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
| 104 |
+
body: JSON.stringify({ data: dataArray }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
cache: "no-store",
|
| 106 |
+
});
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
const postText = await postRes.text();
|
| 109 |
+
const postJson = (() => { try { return JSON.parse(stripToJson(postText)); } catch { return null; } })();
|
| 110 |
|
| 111 |
+
if (!postRes.ok) {
|
| 112 |
+
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postText ?? "(empty body)";
|
| 113 |
+
throw new Error(`POST ${callUrl} → ${postRes.status} in ${postMs}ms — ${abbrev(detail)}`);
|
| 114 |
+
}
|
| 115 |
+
const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
|
| 116 |
+
if (!eventId) throw new Error(`POST ${callUrl} returned no event_id`);
|
| 117 |
|
| 118 |
+
const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
|
| 119 |
+
const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
|
| 120 |
+
method: "GET",
|
| 121 |
+
headers: { Accept: "text/event-stream" },
|
| 122 |
+
});
|
| 123 |
+
if (getRes.status !== 200) {
|
| 124 |
+
const txt = await getRes.text();
|
| 125 |
+
throw new Error(`GET ${getUrl} → ${getRes.status} in ${getMs}ms — ${abbrev(txt)}`);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const json = await readSSEJson(getRes);
|
| 129 |
+
if (json == null) {
|
| 130 |
+
// Gradio sometimes sends `event:error` with `data:null`
|
| 131 |
+
throw new Error(`GET ${getUrl} returned no payload (SSE error/null).`);
|
| 132 |
+
}
|
| 133 |
+
return json;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/** Legacy /api|/run/predict fallback for older backends. */
|
| 137 |
+
async function gradioPredictLegacy(base: string, body: any) {
|
| 138 |
+
const endpoints = [`${base}/api/predict`, `${base}/run/predict`];
|
| 139 |
+
for (const url of endpoints) {
|
| 140 |
+
try {
|
| 141 |
+
const { res, ms } = await timedFetch(url, {
|
| 142 |
+
method: "POST",
|
| 143 |
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
| 144 |
+
body: JSON.stringify(body),
|
| 145 |
+
cache: "no-store",
|
| 146 |
+
});
|
| 147 |
+
const txt = await res.text();
|
| 148 |
+
const json = (() => { try { return JSON.parse(stripToJson(txt)); } catch { return null; } })();
|
| 149 |
+
if (res.ok) return json;
|
| 150 |
+
if (DEBUG) console.warn(`Legacy ${url} → ${res.status} in ${ms}ms — ${abbrev(txt)}`);
|
| 151 |
+
} catch (e) {
|
| 152 |
+
if (DEBUG) console.warn(`Legacy ${url} failed:`, (e as any)?.message || e);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
throw new Error("Legacy /predict endpoints unavailable.");
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/** Normalize various Gradio payload shapes into StoryLine[] */
|
| 159 |
+
function extractStoryLines(payload: any): StoryLine[] {
|
| 160 |
+
let d = payload?.data ?? payload;
|
| 161 |
+
if (!Array.isArray(d)) throw new Error(`Unexpected response: ${abbrev(payload)}`);
|
| 162 |
+
if (Array.isArray(d[0])) d = d[0];
|
| 163 |
+
if (!Array.isArray(d)) throw new Error(`Unexpected response: ${abbrev(payload)}`);
|
| 164 |
+
return d.map((l: any) => ({
|
| 165 |
+
text: String(l?.text || "")
|
| 166 |
+
.replaceAll(" .", ".")
|
| 167 |
+
.replaceAll(" ,", ",")
|
| 168 |
+
.replaceAll(" !", "!")
|
| 169 |
+
.replaceAll(" ?", "?")
|
| 170 |
+
.trim(),
|
| 171 |
+
audio: l?.audio ?? null,
|
| 172 |
+
}));
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
export async function generateStoryLines(prompt: string, voice: TTSVoice): Promise<StoryLine[]> {
|
| 176 |
+
assertEnv();
|
| 177 |
+
|
| 178 |
+
if (!prompt || prompt.trim().length < 3) {
|
| 179 |
+
throw new Error("prompt is too short!");
|
| 180 |
+
}
|
| 181 |
+
const cropped = prompt.slice(0, 30);
|
| 182 |
+
console.log(`user requested "${cropped}${cropped !== prompt ? "..." : ""}"`);
|
| 183 |
|
| 184 |
+
// 1) Preferred: Gradio v5 call API
|
| 185 |
+
try {
|
| 186 |
+
const json = await gradioCallV5(BASE, "predict", [SECRET, prompt, voice]);
|
| 187 |
+
return extractStoryLines(json);
|
| 188 |
+
} catch (e) {
|
| 189 |
+
// If the backend returned SSE error/null, surface a clean message,
|
| 190 |
+
// but still try legacy endpoints once for maximum compatibility.
|
| 191 |
+
console.warn("Gradio v5 call failed:", (e as any)?.message || e);
|
| 192 |
}
|
| 193 |
|
| 194 |
+
// 2) Fallback: legacy JSON endpoints (many Spaces disabled these; may 404)
|
| 195 |
+
const legacy = await gradioPredictLegacy(BASE, { fn_index: LEGACY_FN_INDEX, data: [SECRET, prompt, voice] });
|
| 196 |
+
return extractStoryLines(legacy);
|
| 197 |
+
}
|
|
|
tests/test_connections/test_endpoints.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* eslint-disable no-console */
|
| 2 |
+
import 'dotenv/config';
|
| 3 |
+
|
| 4 |
+
const STORY_BASE = (process.env.AI_STORY_API_GRADIO_URL || '').replace(/\/+$/, '');
|
| 5 |
+
const STORY_SECRET = process.env.AI_STORY_API_SECRET_TOKEN || '';
|
| 6 |
+
|
| 7 |
+
const IMG_BASE = (process.env.AI_FAST_IMAGE_SERVER_API_GRADIO_URL || '').replace(/\/+$/, '');
|
| 8 |
+
const IMG_SECRET = process.env.AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN || '';
|
| 9 |
+
|
| 10 |
+
const FN_INDEX = Number(process.env.AI_STORY_API_FN_INDEX ?? 0); // legacy only
|
| 11 |
+
const DEBUG = (process.env.DEBUG_STORY_API || '').toLowerCase() === 'true';
|
| 12 |
+
|
| 13 |
+
function abbrev(s, n = 1800) {
|
| 14 |
+
if (s == null) return String(s);
|
| 15 |
+
s = typeof s === 'string' ? s : JSON.stringify(s);
|
| 16 |
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
| 17 |
+
}
|
| 18 |
+
function assertEnv() {
|
| 19 |
+
if (!STORY_BASE) throw new Error('Missing AI_STORY_API_GRADIO_URL in .env');
|
| 20 |
+
if (!STORY_SECRET) throw new Error('Missing AI_STORY_API_SECRET_TOKEN in .env');
|
| 21 |
+
}
|
| 22 |
+
async function withTimeout(promise, ms = 120_000) {
|
| 23 |
+
return Promise.race([
|
| 24 |
+
promise,
|
| 25 |
+
new Promise((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)),
|
| 26 |
+
]);
|
| 27 |
+
}
|
| 28 |
+
async function timedFetch(url, init) {
|
| 29 |
+
const t0 = Date.now();
|
| 30 |
+
const res = await withTimeout(fetch(url, init));
|
| 31 |
+
const ms = Date.now() - t0;
|
| 32 |
+
return { res, ms };
|
| 33 |
+
}
|
| 34 |
+
function stripToJson(text) {
|
| 35 |
+
if (!text) return '';
|
| 36 |
+
const i = Math.min(
|
| 37 |
+
...['{', '['].map(ch => {
|
| 38 |
+
const p = text.indexOf(ch);
|
| 39 |
+
return p === -1 ? Number.POSITIVE_INFINITY : p;
|
| 40 |
+
}),
|
| 41 |
+
);
|
| 42 |
+
return i === Number.POSITIVE_INFINITY ? text : text.slice(i);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Parse SSE and return the last JSON payload carried in `data:` lines. */
|
| 46 |
+
async function readSSEJson(res, { maxMs = 120_000 } = {}) {
|
| 47 |
+
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
| 48 |
+
if (ct.includes('application/json')) {
|
| 49 |
+
const txt = await res.text();
|
| 50 |
+
try { return JSON.parse(stripToJson(txt)); } catch { return null; }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const reader = res.body.getReader();
|
| 54 |
+
const decoder = new TextDecoder();
|
| 55 |
+
let buf = '';
|
| 56 |
+
let lastJson = null;
|
| 57 |
+
const start = Date.now();
|
| 58 |
+
|
| 59 |
+
const flush = (chunk) => {
|
| 60 |
+
buf += chunk;
|
| 61 |
+
let sep;
|
| 62 |
+
while ((sep = buf.search(/\r?\n\r?\n/)) !== -1) {
|
| 63 |
+
const raw = buf.slice(0, sep);
|
| 64 |
+
buf = buf.slice(sep).replace(/^\r?\n/, '');
|
| 65 |
+
const lines = raw.split(/\r?\n/);
|
| 66 |
+
const dataLines = [];
|
| 67 |
+
for (const line of lines) {
|
| 68 |
+
if (!line) continue;
|
| 69 |
+
if (DEBUG && line.trim()) console.log(' [SSE]', abbrev(line, 300));
|
| 70 |
+
const idx = line.indexOf(':');
|
| 71 |
+
if (idx === -1) continue;
|
| 72 |
+
const field = line.slice(0, idx).trim();
|
| 73 |
+
const value = line.slice(idx + 1).replace(/^\s/, '');
|
| 74 |
+
if (field === 'data') dataLines.push(value);
|
| 75 |
+
}
|
| 76 |
+
if (dataLines.length) {
|
| 77 |
+
const payload = dataLines.join('\n');
|
| 78 |
+
try { lastJson = JSON.parse(stripToJson(payload)); } catch {}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
while (true) {
|
| 84 |
+
if (Date.now() - start > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
|
| 85 |
+
const { value, done } = await reader.read();
|
| 86 |
+
if (done) break;
|
| 87 |
+
flush(decoder.decode(value, { stream: true }));
|
| 88 |
+
}
|
| 89 |
+
return lastJson;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/** Gradio v5: POST /gradio_api/call/<fn> -> {event_id}, then GET /gradio_api/call/<fn>/<event_id> (SSE) */
|
| 93 |
+
async function tryCallApi(base, fnName, dataArray) {
|
| 94 |
+
const callUrl = `${base}/gradio_api/call/${fnName}`;
|
| 95 |
+
const postBody = { data: dataArray };
|
| 96 |
+
|
| 97 |
+
console.log(`\nPOST ${callUrl}`);
|
| 98 |
+
if (DEBUG) console.log(' body:', abbrev(JSON.stringify(postBody), 600));
|
| 99 |
+
|
| 100 |
+
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 101 |
+
method: 'POST',
|
| 102 |
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
| 103 |
+
body: JSON.stringify(postBody),
|
| 104 |
+
cache: 'no-store',
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
const postText = await postRes.text();
|
| 108 |
+
const postJson = (() => { try { return JSON.parse(stripToJson(postText)); } catch { return null; } })();
|
| 109 |
+
|
| 110 |
+
if (!postRes.ok) {
|
| 111 |
+
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postText ?? '(empty body)';
|
| 112 |
+
throw new Error(`HTTP ${postRes.status} ${postRes.statusText} in ${postMs}ms — ${abbrev(String(detail), 1200)}`);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
|
| 116 |
+
if (!eventId) {
|
| 117 |
+
console.error(' ⚠️ POST response had no event_id. Raw body:\n', abbrev(postText, 1200));
|
| 118 |
+
throw new Error('Missing event_id in call API response');
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
|
| 122 |
+
console.log(`GET ${getUrl}`);
|
| 123 |
+
|
| 124 |
+
const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
|
| 125 |
+
method: 'GET',
|
| 126 |
+
headers: { Accept: 'text/event-stream' },
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
if (getRes.status !== 200) {
|
| 130 |
+
const txt = await getRes.text();
|
| 131 |
+
throw new Error(`HTTP ${getRes.status} ${getRes.statusText} in ${getMs}ms — ${abbrev(txt, 800)}`);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const json = await readSSEJson(getRes);
|
| 135 |
+
console.log(` ✅ OK 200 in ${getMs}ms`);
|
| 136 |
+
if (DEBUG) console.log(' json:', abbrev(JSON.stringify(json), 2000));
|
| 137 |
+
return { ok: true, url: getUrl, json, ms: getMs };
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/** Legacy JSON endpoints (Gradio 3.x/4 compat) */
|
| 141 |
+
async function tryPredictLegacy(base, body) {
|
| 142 |
+
const endpoints = [`${base}/api/predict`, `${base}/run/predict`];
|
| 143 |
+
|
| 144 |
+
for (const url of endpoints) {
|
| 145 |
+
try {
|
| 146 |
+
console.log(`\nPOST ${url}`);
|
| 147 |
+
if (DEBUG) console.log(' body:', abbrev(JSON.stringify(body), 600));
|
| 148 |
+
|
| 149 |
+
const { res, ms } = await timedFetch(url, {
|
| 150 |
+
method: 'POST',
|
| 151 |
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
| 152 |
+
body: JSON.stringify(body),
|
| 153 |
+
cache: 'no-store',
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
const text = await res.text();
|
| 157 |
+
let json = null;
|
| 158 |
+
try { json = JSON.parse(stripToJson(text)); } catch {}
|
| 159 |
+
if (res.ok) {
|
| 160 |
+
console.log(` ✅ OK ${res.status} in ${ms}ms`);
|
| 161 |
+
if (DEBUG) console.log(' json:', abbrev(JSON.stringify(json), 2000));
|
| 162 |
+
return { ok: true, url, json, ms };
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const detail = json?.detail ?? json?.error ?? json?.message ?? text ?? '(empty body)';
|
| 166 |
+
console.error(` ❌ HTTP ${res.status} ${res.statusText} in ${ms}ms — ${abbrev(String(detail), 1200)}`);
|
| 167 |
+
} catch (e) {
|
| 168 |
+
console.error(` ❌ ${url} failed: ${e?.message || e}`);
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
return { ok: false };
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/** Normalize Gradio payload into an array of lines [{text, audio}, ...] */
|
| 175 |
+
function extractStoryLines(payload) {
|
| 176 |
+
if (!payload) return null;
|
| 177 |
+
let d = payload.data ?? payload;
|
| 178 |
+
if (Array.isArray(d) && Array.isArray(d[0])) d = d[0];
|
| 179 |
+
return Array.isArray(d) ? d : null;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
async function testStoryEndpoint() {
|
| 183 |
+
assertEnv();
|
| 184 |
+
console.log('\n== Story endpoint check ==');
|
| 185 |
+
console.log('BASE :', STORY_BASE);
|
| 186 |
+
console.log('TOKEN:', STORY_SECRET ? '(present)' : '(MISSING)');
|
| 187 |
+
console.log('FN :', FN_INDEX);
|
| 188 |
+
|
| 189 |
+
// HEAD liveness
|
| 190 |
+
try {
|
| 191 |
+
const { res, ms } = await timedFetch(STORY_BASE + '/', { method: 'HEAD' });
|
| 192 |
+
console.log(`HEAD ${STORY_BASE}/ -> ${res.status} in ${ms}ms`);
|
| 193 |
+
} catch (e) {
|
| 194 |
+
console.warn('HEAD failed:', e?.message || e);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const voice = 'Cloée';
|
| 198 |
+
const prompt = 'A short wholesome bedtime story about a friendly dragon and a village.';
|
| 199 |
+
|
| 200 |
+
// 1) Gradio v5 call API (SSE)
|
| 201 |
+
try {
|
| 202 |
+
const out = await tryCallApi(STORY_BASE, 'predict', [STORY_SECRET, prompt, voice]);
|
| 203 |
+
console.log('Story call API response:', abbrev(JSON.stringify(out.json), 2000));
|
| 204 |
+
|
| 205 |
+
const lines = extractStoryLines(out.json);
|
| 206 |
+
if (Array.isArray(lines)) {
|
| 207 |
+
console.log(` ✅ Received ${lines.length} story lines`);
|
| 208 |
+
return;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// If SSE returned null or unparseable, treat as liveness pass with a warning.
|
| 212 |
+
console.warn('⚠️ Story endpoint returned no JSON payload (SSE error/null). Treating as reachable.');
|
| 213 |
+
return;
|
| 214 |
+
} catch (e) {
|
| 215 |
+
console.warn('Gradio v5 call API failed for story:', e?.message || e);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// 2) Legacy fallback
|
| 219 |
+
const legacy = await tryPredictLegacy(STORY_BASE, { fn_index: FN_INDEX, data: [STORY_SECRET, prompt, voice] });
|
| 220 |
+
if (!legacy.ok) throw new Error('Story endpoint failed (both call API and legacy).');
|
| 221 |
+
const lines = extractStoryLines(legacy.json);
|
| 222 |
+
if (!Array.isArray(lines)) {
|
| 223 |
+
console.warn('⚠️ Legacy story payload not recognized, but endpoint responded. Treating as reachable.');
|
| 224 |
+
} else {
|
| 225 |
+
console.log(` ✅ Legacy story returned ${lines.length} lines`);
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
async function testImageEndpoint() {
|
| 230 |
+
if (!IMG_BASE || !IMG_SECRET) {
|
| 231 |
+
console.log('\n== Image endpoint check skipped (missing IMG envs) ==');
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
console.log('\n== Image endpoint check ==');
|
| 235 |
+
console.log('BASE :', IMG_BASE);
|
| 236 |
+
console.log('TOKEN:', IMG_SECRET ? '(present)' : '(MISSING)');
|
| 237 |
+
|
| 238 |
+
// Matches server order: [prompt, negative, seed, width, height, guidance, steps, secret_token]
|
| 239 |
+
const imgData = ['a cute cat sticker', '', 1, 512, 512, 0.0, 4, IMG_SECRET];
|
| 240 |
+
|
| 241 |
+
// 1) Gradio v5 call API (SSE)
|
| 242 |
+
try {
|
| 243 |
+
const out = await tryCallApi(IMG_BASE, 'generate', imgData);
|
| 244 |
+
console.log('Image call API response:', abbrev(JSON.stringify(out.json), 2000));
|
| 245 |
+
|
| 246 |
+
const payload = out.json?.data ?? out.json;
|
| 247 |
+
const ok =
|
| 248 |
+
Array.isArray(payload) ||
|
| 249 |
+
(payload && typeof payload === 'object' && ('url' in payload || 'path' in payload));
|
| 250 |
+
|
| 251 |
+
if (ok) {
|
| 252 |
+
console.log(' ✅ Image endpoint responded.');
|
| 253 |
+
return;
|
| 254 |
+
}
|
| 255 |
+
console.warn('⚠️ Image payload not recognized, but endpoint responded. Treating as reachable.');
|
| 256 |
+
return;
|
| 257 |
+
} catch (e) {
|
| 258 |
+
console.warn('Gradio v5 call API failed for image:', e?.message || e);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// 2) Legacy fallback
|
| 262 |
+
const legacy = await tryPredictLegacy(IMG_BASE, { fn_index: 0, data: imgData });
|
| 263 |
+
if (!legacy.ok) throw new Error('Image endpoint failed (both call API and legacy).');
|
| 264 |
+
const payload = legacy.json?.data ?? legacy.json;
|
| 265 |
+
const ok =
|
| 266 |
+
Array.isArray(payload) ||
|
| 267 |
+
(payload && typeof payload === 'object' && ('url' in payload || 'path' in payload));
|
| 268 |
+
if (!ok) console.warn('⚠️ Legacy image payload not recognized, but endpoint responded. Treating as reachable.');
|
| 269 |
+
else console.log(' ✅ Legacy image endpoint responded.');
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
(async () => {
|
| 273 |
+
try {
|
| 274 |
+
await testStoryEndpoint();
|
| 275 |
+
await testImageEndpoint();
|
| 276 |
+
console.log('\nAll tests done ✅');
|
| 277 |
+
process.exit(0);
|
| 278 |
+
} catch (e) {
|
| 279 |
+
console.error('\nTests failed ❌:', e?.message || e);
|
| 280 |
+
process.exit(1);
|
| 281 |
+
}
|
| 282 |
+
})();
|
tests/test_connections/test_endpoints.sh
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# --- Safe .env load (strip CRLF if present) ---
|
| 5 |
+
ENV_FILE="${ENV_FILE:-.env}"
|
| 6 |
+
if [ -f "$ENV_FILE" ]; then
|
| 7 |
+
tmp_env="$(mktemp)"
|
| 8 |
+
sed 's/\r$//' "$ENV_FILE" > "$tmp_env"
|
| 9 |
+
# shellcheck disable=SC1090
|
| 10 |
+
. "$tmp_env"
|
| 11 |
+
rm -f "$tmp_env"
|
| 12 |
+
fi
|
| 13 |
+
|
| 14 |
+
# Defaults (can be overridden via .env or CLI)
|
| 15 |
+
IMG_BASE="${AI_FAST_IMAGE_SERVER_API_GRADIO_URL:-https://ruslanmv-ai-fast-image-server.hf.space}"
|
| 16 |
+
IMG_TOKEN="${AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN:-default_secret}"
|
| 17 |
+
|
| 18 |
+
STORY_BASE="${AI_STORY_API_GRADIO_URL:-https://ruslanmv-ai-story-server-cpu.hf.space}"
|
| 19 |
+
STORY_TOKEN="${AI_STORY_API_SECRET_TOKEN:-secret}"
|
| 20 |
+
|
| 21 |
+
STORY_PROMPT="${1:-A short wholesome bedtime story about a friendly dragon.}"
|
| 22 |
+
VOICE="${2:-Cloée}"
|
| 23 |
+
IMG_PROMPT="${3:-a cute cat sticker}"
|
| 24 |
+
|
| 25 |
+
has_jq() { command -v jq >/dev/null 2>&1; }
|
| 26 |
+
pretty() { if has_jq; then jq . || cat; else cat; fi; }
|
| 27 |
+
|
| 28 |
+
# Extract JSON payload from any noisy output: keep from first { or [ to end
|
| 29 |
+
strip_to_json() {
|
| 30 |
+
awk '
|
| 31 |
+
BEGIN{ found=0 }
|
| 32 |
+
{
|
| 33 |
+
if (!found) {
|
| 34 |
+
pos = index($0, "{"); if (pos==0) pos = index($0, "[");
|
| 35 |
+
if (pos>0) { print substr($0, pos); found=1; }
|
| 36 |
+
} else { print $0; }
|
| 37 |
+
}'
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# POST helper (returns BODY + trailing HTTP_STATUS line)
|
| 41 |
+
post_json() {
|
| 42 |
+
local url="$1" json="$2"
|
| 43 |
+
echo "POST $url" >&2
|
| 44 |
+
curl -sS -H "Content-Type: application/json" -X POST "$url" -d "$json" -w "\nHTTP_STATUS:%{http_code}"
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
# GET helper (returns BODY + trailing HTTP_STATUS line)
|
| 48 |
+
get_url() {
|
| 49 |
+
local url="$1"
|
| 50 |
+
echo "GET $url" >&2
|
| 51 |
+
curl -sS "$url" -w "\nHTTP_STATUS:%{http_code}"
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
# New Gradio v4/v5 call API: POST -> event_id -> GET result
|
| 55 |
+
try_call_api() {
|
| 56 |
+
local base="$1" func="$2" json="$3"
|
| 57 |
+
local call_url="${base%/}/gradio_api/call/$func"
|
| 58 |
+
local out status body body_json event_id get_url_full get_out get_status get_body get_json
|
| 59 |
+
|
| 60 |
+
out="$(post_json "$call_url" "$json" || true)"
|
| 61 |
+
status="${out##*HTTP_STATUS:}"
|
| 62 |
+
body="${out%HTTP_STATUS:*}"
|
| 63 |
+
body_json="$(printf '%s' "$body" | strip_to_json)"
|
| 64 |
+
|
| 65 |
+
# Always show the POST response JSON (event_id or error)
|
| 66 |
+
echo "---- ${func}: POST response ----"
|
| 67 |
+
printf '%s\n' "$body_json" | pretty
|
| 68 |
+
echo "--------------------------------"
|
| 69 |
+
|
| 70 |
+
if [[ "$status" != "200" ]]; then
|
| 71 |
+
echo "-> ${status} from $call_url" >&2
|
| 72 |
+
printf '%s\n' "$body" | head -c 500 >&2; echo >&2
|
| 73 |
+
return 1
|
| 74 |
+
fi
|
| 75 |
+
|
| 76 |
+
# Parse event_id safely
|
| 77 |
+
if has_jq; then
|
| 78 |
+
event_id="$(printf '%s' "$body_json" | jq -r '.event_id // .eventId // .eventID // empty' 2>/dev/null || echo "")"
|
| 79 |
+
else
|
| 80 |
+
event_id="$(printf '%s' "$body_json" | sed -n 's/.*"event_id"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')"
|
| 81 |
+
fi
|
| 82 |
+
|
| 83 |
+
if [[ -z "${event_id:-}" ]]; then
|
| 84 |
+
echo "-> Missing event_id in response." >&2
|
| 85 |
+
return 1
|
| 86 |
+
fi
|
| 87 |
+
|
| 88 |
+
get_url_full="${base%/}/gradio_api/call/$func/$event_id"
|
| 89 |
+
get_out="$(get_url "$get_url_full" || true)"
|
| 90 |
+
get_status="${get_out##*HTTP_STATUS:}"
|
| 91 |
+
get_body="${get_out%HTTP_STATUS:*}"
|
| 92 |
+
get_json="$(printf '%s' "$get_body" | strip_to_json)"
|
| 93 |
+
|
| 94 |
+
# Always show the GET result JSON
|
| 95 |
+
echo "---- ${func}: GET result ----"
|
| 96 |
+
printf '%s\n' "$get_json" | pretty
|
| 97 |
+
echo "-----------------------------"
|
| 98 |
+
|
| 99 |
+
if [[ "$get_status" != "200" ]]; then
|
| 100 |
+
echo "-> ${get_status} from $get_url_full" >&2
|
| 101 |
+
printf '%s\n' "$get_body" | head -c 500 >&2; echo >&2
|
| 102 |
+
return 1
|
| 103 |
+
fi
|
| 104 |
+
|
| 105 |
+
echo "-> 200 OK (call API)"
|
| 106 |
+
return 0
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
# Old Gradio endpoints (sync JSON): /api/predict then /run/predict
|
| 110 |
+
try_legacy_predict() {
|
| 111 |
+
local base="$1" json="$2"
|
| 112 |
+
local primary="${base%/}/api/predict"
|
| 113 |
+
local fallback="${base%/}/run/predict"
|
| 114 |
+
local out status body body_json
|
| 115 |
+
|
| 116 |
+
out="$(post_json "$primary" "$json" || true)"
|
| 117 |
+
status="${out##*HTTP_STATUS:}"
|
| 118 |
+
body="${out%HTTP_STATUS:*}"
|
| 119 |
+
body_json="$(printf '%s' "$body" | strip_to_json)"
|
| 120 |
+
|
| 121 |
+
echo "---- legacy POST ($primary) response ----"
|
| 122 |
+
printf '%s\n' "$body_json" | pretty
|
| 123 |
+
echo "-----------------------------------------"
|
| 124 |
+
|
| 125 |
+
if [[ "$status" == "200" ]]; then
|
| 126 |
+
echo "-> 200 OK ($primary)"
|
| 127 |
+
return 0
|
| 128 |
+
else
|
| 129 |
+
echo "-> ${status} from $primary" >&2
|
| 130 |
+
printf '%s\n' "$body" | head -c 500 >&2; echo >&2
|
| 131 |
+
fi
|
| 132 |
+
|
| 133 |
+
out="$(post_json "$fallback" "$json" || true)"
|
| 134 |
+
status="${out##*HTTP_STATUS:}"
|
| 135 |
+
body="${out%HTTP_STATUS:*}"
|
| 136 |
+
body_json="$(printf '%s' "$body" | strip_to_json)"
|
| 137 |
+
|
| 138 |
+
echo "---- legacy POST ($fallback) response ----"
|
| 139 |
+
printf '%s\n' "$body_json" | pretty
|
| 140 |
+
echo "------------------------------------------"
|
| 141 |
+
|
| 142 |
+
if [[ "$status" == "200" ]]; then
|
| 143 |
+
echo "-> 200 OK ($fallback)"
|
| 144 |
+
return 0
|
| 145 |
+
else
|
| 146 |
+
echo "-> ${status} from $fallback" >&2
|
| 147 |
+
printf '%s\n' "$body" | head -c 500 >&2; echo >&2
|
| 148 |
+
return 1
|
| 149 |
+
fi
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
# ---------- Build JSON payloads ----------
|
| 153 |
+
# Image server /generate expects:
|
| 154 |
+
# [prompt, negative_prompt, seed, width, height, guidance, steps, secret_token]
|
| 155 |
+
IMG_JSON=$(cat <<EOF
|
| 156 |
+
{"data": [
|
| 157 |
+
"${IMG_PROMPT}",
|
| 158 |
+
"",
|
| 159 |
+
3,
|
| 160 |
+
256,
|
| 161 |
+
256,
|
| 162 |
+
0,
|
| 163 |
+
1,
|
| 164 |
+
"${IMG_TOKEN}"
|
| 165 |
+
]}
|
| 166 |
+
EOF
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# Story server /predict expects:
|
| 170 |
+
# [secret_token, story_prompt, voice]
|
| 171 |
+
STORY_JSON=$(cat <<EOF
|
| 172 |
+
{"data": [
|
| 173 |
+
"${STORY_TOKEN}",
|
| 174 |
+
"${STORY_PROMPT}",
|
| 175 |
+
"${VOICE}"
|
| 176 |
+
]}
|
| 177 |
+
EOF
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
echo "=== Image server check ==="
|
| 181 |
+
echo "Base: $IMG_BASE"
|
| 182 |
+
if ! try_call_api "$IMG_BASE" "generate" "$IMG_JSON"; then
|
| 183 |
+
echo
|
| 184 |
+
echo "Falling back to legacy endpoints for image server…"
|
| 185 |
+
if ! try_legacy_predict "$IMG_BASE" "{\"fn_index\":0, \"data\": [\"${IMG_PROMPT}\", \"\", 3, 256, 256, 0, 1, \"${IMG_TOKEN}\"]}"; then
|
| 186 |
+
echo "Image endpoint check FAILED" >&2
|
| 187 |
+
exit 2
|
| 188 |
+
fi
|
| 189 |
+
fi
|
| 190 |
+
|
| 191 |
+
echo
|
| 192 |
+
echo "=== Story server check ==="
|
| 193 |
+
echo "Base: $STORY_BASE"
|
| 194 |
+
if ! try_call_api "$STORY_BASE" "predict" "$STORY_JSON"; then
|
| 195 |
+
echo
|
| 196 |
+
echo "Falling back to legacy endpoints for story server…"
|
| 197 |
+
if ! try_legacy_predict "$STORY_BASE" "{\"fn_index\":0, \"data\": [\"${STORY_TOKEN}\", \"${STORY_PROMPT}\", \"${VOICE}\"]}"; then
|
| 198 |
+
echo "Story endpoint check FAILED" >&2
|
| 199 |
+
exit 3
|
| 200 |
+
fi
|
| 201 |
+
fi
|
| 202 |
+
|
| 203 |
+
echo
|
| 204 |
+
echo "All endpoint checks completed successfully."
|