ruslanmv commited on
Commit
021b323
·
1 Parent(s): 0021fbd

Version 5 Gradio

Browse files
.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
- // note: to reduce costs I use the small A10s (not the large)
10
- // anyway, we will soon not need to use this cloud anymore
11
- // since we will be able to leverage the Inference API
12
- const instance = `${process.env.AI_FAST_IMAGE_SERVER_API_GRADIO_URL || ""}`
13
- const secretToken = `${process.env.AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN || ""}`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- // console.log("DEBUG:", JSON.stringify({ instances, secretToken }, null, 2))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // console.log("querying " + instance)
27
- const positivePrompt = options?.positivePrompt || ""
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 width = getValidNumber(options?.width, 256, 1024, 512)
41
- const height = getValidNumber(options?.height, 256, 1024, 512)
42
- const nbSteps = getValidNumber(options?.nbSteps, 1, 8, 4)
43
- // console.log("SEED:", seed)
 
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(word => word)
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(word => word)
70
- .join(", ")
71
-
72
- const res = await fetch(instance + (instance.endsWith("/") ? "" : "/") + "api/predict", {
73
- method: "POST",
74
- headers: {
75
- "Content-Type": "application/json",
76
- // Authorization: `Bearer ${token}`,
77
- },
78
- body: JSON.stringify({
79
- fn_index: 0, // <- important!
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
- return data[0] as string
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 { Story, StoryLine, TTSVoice } from "@/types"
 
4
 
5
- const instance = `${process.env.AI_STORY_API_GRADIO_URL || ""}`
6
- const secretToken = `${process.env.AI_STORY_API_SECRET_TOKEN || ""}`
 
 
7
 
8
- export async function generateStoryLines(prompt: string, voice: TTSVoice): Promise<StoryLine[]> {
9
- if (!prompt?.length) {
10
- throw new Error(`prompt is too short!`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  }
12
 
13
- const cropped = prompt.slice(0, 30)
14
- console.log(`user requested "${cropped}${cropped !== prompt ? "..." : ""}"`)
 
15
 
16
- // positivePrompt = filterOutBadWords(positivePrompt)
 
 
 
17
 
18
- const res = await fetch(instance + (instance.endsWith("/") ? "" : "/") + "api/predict", {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  method: "POST",
20
- headers: {
21
- "Content-Type": "application/json",
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
- // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache)
34
- // next: { revalidate: 1 }
35
- })
36
 
 
 
37
 
38
- const rawJson = await res.json()
39
- const data = rawJson.data as StoryLine[][]
 
 
 
 
40
 
41
- const stories = data?.[0] || []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- if (res.status !== 200) {
44
- throw new Error('Failed to fetch data')
 
 
 
 
 
 
45
  }
46
 
47
- return stories.map(line => ({
48
- text: line.text.replaceAll(" .", ".").replaceAll(" ?", "?").replaceAll(" !", "!").trim(),
49
- audio: line.audio
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."