ruslanmv commited on
Commit
043b349
·
1 Parent(s): cccb8be

First commit

Browse files
.gitignore CHANGED
@@ -37,3 +37,4 @@ next-env.d.ts
37
  /sandbox/
38
  .env
39
  .env
 
 
37
  /sandbox/
38
  .env
39
  .env
40
+ /backup
src/app/server/actions/generateImage.ts CHANGED
@@ -6,6 +6,7 @@ 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() {
@@ -13,9 +14,16 @@ function assertEnv() {
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) {
@@ -24,122 +32,235 @@ async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
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
 
@@ -160,36 +281,29 @@ export async function generateImage(options: {
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
  }
 
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 HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || "";
10
  const DEBUG = (process.env.DEBUG_IMAGE_API || "").toLowerCase() === "true";
11
 
12
  function assertEnv() {
 
14
  if (!SECRET) throw new Error("Missing AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN");
15
  }
16
 
17
+ function authHeaders(): Record<string, string> {
18
+ const h: Record<string, string> = {};
19
+ if (HF_TOKEN) h.Authorization = `Bearer ${HF_TOKEN}`;
20
+ return h;
21
+ }
22
+
23
  function abbrev(s: unknown, n = 1200) {
24
+ if (s == null) return String(s);
25
  const t = typeof s === "string" ? s : JSON.stringify(s);
26
+ return t.length > n ? t.slice(0, n) + "…" : t;
27
  }
28
 
29
  async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
 
32
  new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)) as Promise<T>,
33
  ]);
34
  }
35
+
36
  async function timedFetch(url: string, init?: RequestInit) {
37
  const t0 = Date.now();
38
+ const res = await withTimeout(fetch(url, init), 120_000);
39
  return { res, ms: Date.now() - t0 };
40
  }
41
+
42
  function stripToJson(text: string) {
43
  if (!text) return "";
44
  const iBrace = text.indexOf("{");
45
  const iBrack = text.indexOf("[");
46
+ const picks = [iBrace, iBrack].filter((i) => i >= 0);
47
+ if (!picks.length) return text;
48
+ const i = Math.min(...picks);
49
+ return text.slice(i);
50
  }
51
+
52
+ /** Parse Gradio SSE and return the JSON payload of the **complete** event (throw on error). */
53
+ async function readSSEComplete(res: Response, { maxMs = 120_000 } = {}): Promise<any | null> {
54
  const ct = (res.headers.get("content-type") || "").toLowerCase();
55
+
56
+ // Some deployments return JSON directly (no SSE) — accept that.
57
  if (ct.includes("application/json")) {
58
  const txt = await res.text();
59
+ try {
60
+ return JSON.parse(stripToJson(txt));
61
+ } catch {
62
+ return null;
63
+ }
64
  }
65
+
66
  const reader = res.body?.getReader();
67
  if (!reader) return null;
68
+
69
  const decoder = new TextDecoder();
70
  let buf = "";
71
+ let currEvent: string | null = null;
72
+ let dataLines: string[] = [];
73
+ const started = Date.now();
74
+
75
+ const handleBlock = () => {
76
+ if (!currEvent) return undefined;
77
+ const payloadText = dataLines.join("\n");
78
+ if (DEBUG) console.log(` [SSE event=${currEvent}] ${abbrev(payloadText, 600)}`);
79
+
80
+ if (currEvent === "complete") {
81
+ if (!payloadText) return { type: "complete", json: null as any };
82
+ try {
83
+ return { type: "complete", json: JSON.parse(stripToJson(payloadText)) };
84
+ } catch {
85
+ return { type: "complete", json: null as any };
 
 
 
86
  }
87
+ }
88
+
89
+ if (currEvent === "error") {
90
+ let detail: unknown = null;
91
+ try {
92
+ detail = payloadText ? JSON.parse(stripToJson(payloadText)) : null;
93
+ } catch {
94
+ detail = payloadText;
95
  }
96
+ return { type: "error", detail };
97
  }
98
+
99
+ return undefined;
100
  };
101
 
102
  while (true) {
103
+ if (Date.now() - started > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
104
  const { value, done } = await reader.read();
105
  if (done) break;
106
+
107
+ buf += decoder.decode(value, { stream: true });
108
+
109
+ // split stream into event blocks on blank line
110
+ let idx: number;
111
+ while ((idx = buf.search(/\r?\n\r?\n/)) !== -1) {
112
+ const raw = buf.slice(0, idx);
113
+ buf = buf.slice(idx).replace(/^\r?\n\r?\n/, ""); // drop exactly one blank separator
114
+ const lines = raw.split(/\r?\n/);
115
+
116
+ // parse fields in this block
117
+ currEvent = null;
118
+ dataLines = [];
119
+ for (const line of lines) {
120
+ if (!line) continue;
121
+ if (line[0] === ":") continue; // comment/keepalive
122
+ const c = line.indexOf(":");
123
+ const field = (c === -1 ? line : line.slice(0, c)).trim();
124
+ const val = (c === -1 ? "" : line.slice(c + 1)).replace(/^\s/, "");
125
+ if (field === "event") currEvent = val;
126
+ else if (field === "data") dataLines.push(val);
127
+ }
128
+
129
+ const outcome = handleBlock();
130
+ if (outcome?.type === "complete") return outcome.json;
131
+ if (outcome?.type === "error") {
132
+ const msg = typeof outcome.detail === "string" ? outcome.detail : JSON.stringify(outcome.detail);
133
+ throw new Error(`SSE error event: ${abbrev(msg ?? "null", 1200)}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ // process any trailing block (stream ended without final blank line)
139
+ if (buf.trim().length) {
140
+ const lines = buf.split(/\r?\n/);
141
+ currEvent = null;
142
+ dataLines = [];
143
+ for (const line of lines) {
144
+ if (!line || line[0] === ":") continue;
145
+ const c = line.indexOf(":");
146
+ const field = (c === -1 ? line : line.slice(0, c)).trim();
147
+ const val = (c === -1 ? "" : line.slice(c + 1)).replace(/^\s/, "");
148
+ if (field === "event") currEvent = val;
149
+ else if (field === "data") dataLines.push(val);
150
+ }
151
+ const outcome = handleBlock();
152
+ if (outcome?.type === "complete") return outcome.json;
153
+ if (outcome?.type === "error") {
154
+ const msg = typeof outcome.detail === "string" ? outcome.detail : JSON.stringify(outcome.detail);
155
+ throw new Error(`SSE error event: ${abbrev(msg ?? "null", 1200)}`);
156
+ }
157
  }
158
+
159
+ return null;
160
  }
161
 
162
+ /** v5 call API: POST /gradio_api/call/<fn> → {event_id} → GET /gradio_api/call/<fn>/<event_id> (SSE complete). */
163
  async function gradioCallV5(base: string, fnName: string, dataArray: any[]) {
164
  const callUrl = `${base}/gradio_api/call/${fnName}`;
165
+
166
  const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
167
  method: "POST",
168
+ headers: {
169
+ "Content-Type": "application/json",
170
+ Accept: "application/json",
171
+ ...authHeaders(),
172
+ },
173
  body: JSON.stringify({ data: dataArray }),
174
  cache: "no-store",
175
  });
176
+
177
  const postTxt = await postRes.text();
178
+ const postJson = (() => {
179
+ try {
180
+ return JSON.parse(stripToJson(postTxt));
181
+ } catch {
182
+ return null;
183
+ }
184
+ })();
185
+
186
  if (!postRes.ok) {
187
  const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postTxt ?? "(empty body)";
188
  throw new Error(`POST ${callUrl} → ${postRes.status} in ${postMs}ms — ${abbrev(detail)}`);
189
  }
190
+
191
  const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
192
  if (!eventId) throw new Error(`POST ${callUrl} returned no event_id`);
193
 
194
  const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
195
+ const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
196
+ method: "GET",
197
+ headers: {
198
+ Accept: "text/event-stream",
199
+ Connection: "keep-alive",
200
+ ...authHeaders(),
201
+ },
202
+ cache: "no-store",
203
+ });
204
+
205
  if (getRes.status !== 200) {
206
  const txt = await getRes.text();
207
  throw new Error(`GET ${getUrl} → ${getRes.status} in ${getMs}ms — ${abbrev(txt)}`);
208
  }
209
+
210
+ const json = await readSSEComplete(getRes);
211
+ if (json == null) {
212
+ // Common causes: invalid secret (server raises gr.Error), or ZeroGPU quota/denied.
213
+ throw new Error(`GET ${getUrl} returned no payload (SSE error/null — check secret/quota).`);
214
+ }
215
  return json;
216
  }
217
 
218
+ /** Legacy /api|/run/predict fallback (rarely present on v5 Spaces). */
219
  async function gradioPredictLegacy(base: string, body: any) {
220
  const endpoints = [`${base}/api/predict`, `${base}/run/predict`];
221
  for (const url of endpoints) {
222
  try {
223
+ const { res, ms } = await timedFetch(url, {
224
  method: "POST",
225
+ headers: {
226
+ "Content-Type": "application/json",
227
+ Accept: "application/json",
228
+ ...authHeaders(),
229
+ },
230
  body: JSON.stringify(body),
231
  cache: "no-store",
232
  });
233
  const txt = await res.text();
234
+ const json = (() => {
235
+ try {
236
+ return JSON.parse(stripToJson(txt));
237
+ } catch {
238
+ return null;
239
+ }
240
+ })();
241
  if (res.ok) return json;
242
+ if (DEBUG) console.warn(`Legacy ${url} ${res.status} in ${ms}ms — ${abbrev(txt)}`);
243
+ } catch (e) {
244
+ if (DEBUG) console.warn(`Legacy ${url} failed:`, (e as any)?.message || e);
245
+ }
246
  }
247
  throw new Error("Legacy /predict endpoints unavailable.");
248
  }
249
 
250
+ /** Normalize image payload to a single URL string (or data URI). */
251
  function extractImageUrl(payload: any): string {
252
+ // v5 often returns: { data: [ { url, name, size, … } ] }
253
+ let d: any = payload?.data ?? payload;
254
+
255
+ if (Array.isArray(d) && d.length > 0) {
256
  const first = d[0];
257
+ if (typeof first === "string") return first; // direct url/data URI
258
+ if (first?.url) return String(first.url); // FileData.url
259
+ if (first?.path) return String(first.path); // legacy file path
260
  }
261
  if (d?.url) return String(d.url);
262
+ if (d?.path) return String(d.path);
263
+
264
  throw new Error(`Unexpected image response: ${abbrev(payload)}`);
265
  }
266
 
 
281
  const seed = (options?.seed ? options.seed : 0) || generateSeed();
282
  const width = getValidNumber(options?.width, 256, 1024, 512);
283
  const height = getValidNumber(options?.height, 256, 1024, 512);
284
+ const nbSteps = getValidNumber(options?.nbSteps, 1, 12, 4); // server clamps to <=12
285
+
286
+ const positive = ["beautiful", positivePrompt, "award winning", "high resolution"]
287
+ .filter(Boolean)
288
+ .join(", ");
289
+
290
+ const negative = [negativePrompt, "watermark", "copyright", "blurry", "low quality", "ugly"]
291
+ .filter(Boolean)
292
+ .join(", ");
 
 
 
 
 
 
 
 
293
 
294
  // Order must match the Space: [prompt, negative, seed, width, height, guidance, steps, secret_token]
295
  const dataArray = [positive, negative, seed, width, height, 0.0, nbSteps, SECRET];
296
 
297
+ // 1) Gradio v5 call API (preferred)
298
  try {
299
  const json = await gradioCallV5(BASE, "generate", dataArray);
300
  return extractImageUrl(json);
301
  } catch (e) {
302
+ // If you see "SSE error/null", it’s almost always invalid secret or quota.
303
  console.warn("Gradio v5 image call failed:", (e as any)?.message || e);
304
  }
305
 
306
+ // 2) Legacy fallback (very likely 404 on v5 Spaces)
307
  const legacy = await gradioPredictLegacy(BASE, { fn_index: 0, data: dataArray });
308
  return extractImageUrl(legacy);
309
  }
src/app/server/actions/generateStoryLines.ts CHANGED
@@ -3,9 +3,11 @@
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() {
@@ -13,9 +15,16 @@ function assertEnv() {
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) {
@@ -27,7 +36,7 @@ async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
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
 
@@ -35,99 +44,174 @@ 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;
@@ -140,12 +224,24 @@ async function gradioPredictLegacy(base: string, body: any) {
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) {
@@ -158,11 +254,11 @@ async function gradioPredictLegacy(base: string, body: any) {
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(" !", "!")
@@ -179,19 +275,17 @@ export async function generateStoryLines(prompt: string, voice: TTSVoice): Promi
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
  }
 
3
  import "server-only";
4
  import type { StoryLine, TTSVoice } from "@/types";
5
 
6
+ // ---- env / config ----
7
  const BASE = (process.env.AI_STORY_API_GRADIO_URL || "").replace(/\/+$/, "");
8
  const SECRET = process.env.AI_STORY_API_SECRET_TOKEN || "";
9
+ const LEGACY_FN_INDEX = Number(process.env.AI_STORY_API_FN_INDEX ?? 0); // fallback only
10
+ const HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || "";
11
  const DEBUG = (process.env.DEBUG_STORY_API || "").toLowerCase() === "true";
12
 
13
  function assertEnv() {
 
15
  if (!SECRET) throw new Error("Missing AI_STORY_API_SECRET_TOKEN");
16
  }
17
 
18
+ function authHeaders(): Record<string, string> {
19
+ const h: Record<string, string> = {};
20
+ if (HF_TOKEN) h.Authorization = `Bearer ${HF_TOKEN}`;
21
+ return h;
22
+ }
23
+
24
  function abbrev(s: unknown, n = 1200) {
25
+ if (s == null) return String(s);
26
  const t = typeof s === "string" ? s : JSON.stringify(s);
27
+ return t.length > n ? t.slice(0, n) + "…" : t;
28
  }
29
 
30
  async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
 
36
 
37
  async function timedFetch(url: string, init?: RequestInit) {
38
  const t0 = Date.now();
39
+ const res = await withTimeout(fetch(url, init), 120_000);
40
  return { res, ms: Date.now() - t0 };
41
  }
42
 
 
44
  if (!text) return "";
45
  const iBrace = text.indexOf("{");
46
  const iBrack = text.indexOf("[");
47
+ const picks = [iBrace, iBrack].filter((i) => i >= 0);
48
+ if (!picks.length) return text;
49
+ const i = Math.min(...picks);
50
+ return text.slice(i);
51
  }
52
 
53
+ /** Parse SSE and return the JSON payload of the **complete** event (throws on error). */
54
+ async function readSSEComplete(res: Response, { maxMs = 120_000 } = {}): Promise<any | null> {
55
  const ct = (res.headers.get("content-type") || "").toLowerCase();
56
+
57
+ // Some deployments return plain JSON (no SSE) — accept that.
58
  if (ct.includes("application/json")) {
59
  const txt = await res.text();
60
+ try {
61
+ return JSON.parse(stripToJson(txt));
62
+ } catch {
63
+ return null;
64
+ }
65
  }
66
 
 
67
  const reader = res.body?.getReader();
68
  if (!reader) return null;
69
 
70
  const decoder = new TextDecoder();
71
  let buf = "";
72
+ let currEvent: string | null = null;
73
+ let dataLines: string[] = [];
74
+ const started = Date.now();
75
+
76
+ const handleBlock = () => {
77
+ if (!currEvent) return undefined;
78
+ const payloadText = dataLines.join("\n");
79
+ if (DEBUG) console.log(` [SSE event=${currEvent}] ${abbrev(payloadText, 600)}`);
80
+
81
+ if (currEvent === "complete") {
82
+ if (!payloadText) return { type: "complete", json: null as any };
83
+ try {
84
+ return { type: "complete", json: JSON.parse(stripToJson(payloadText)) };
85
+ } catch {
86
+ return { type: "complete", json: null as any };
 
 
 
 
87
  }
88
+ }
89
+
90
+ if (currEvent === "error") {
91
+ let detail: unknown = null;
92
+ try {
93
+ detail = payloadText ? JSON.parse(stripToJson(payloadText)) : null;
94
+ } catch {
95
+ detail = payloadText;
96
  }
97
+ return { type: "error", detail };
98
  }
99
+
100
+ return undefined;
101
  };
102
 
103
  while (true) {
104
+ if (Date.now() - started > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
105
  const { value, done } = await reader.read();
106
  if (done) break;
107
+
108
+ buf += decoder.decode(value, { stream: true });
109
+
110
+ // split stream into event blocks on blank line
111
+ let idx: number;
112
+ // handle consecutive CRLF CRLF properly
113
+ while ((idx = buf.search(/\r?\n\r?\n/)) !== -1) {
114
+ const raw = buf.slice(0, idx);
115
+ buf = buf.slice(idx).replace(/^\r?\n\r?\n/, ""); // remove exactly one blank-separator
116
+ const lines = raw.split(/\r?\n/);
117
+
118
+ // parse fields in this block
119
+ currEvent = null;
120
+ dataLines = [];
121
+ for (const line of lines) {
122
+ if (!line) continue;
123
+ if (line[0] === ":") continue; // comment/keepalive
124
+ const c = line.indexOf(":");
125
+ const field = (c === -1 ? line : line.slice(0, c)).trim();
126
+ const val = (c === -1 ? "" : line.slice(c + 1)).replace(/^\s/, "");
127
+ if (field === "event") currEvent = val;
128
+ else if (field === "data") dataLines.push(val);
129
+ }
130
+
131
+ const outcome = handleBlock();
132
+ if (outcome?.type === "complete") return outcome.json;
133
+ if (outcome?.type === "error") {
134
+ const msg = typeof outcome.detail === "string" ? outcome.detail : JSON.stringify(outcome.detail);
135
+ throw new Error(`SSE error event: ${abbrev(msg ?? "null", 1200)}`);
136
+ }
137
+ }
138
+ }
139
+
140
+ // Process any trailing block (if stream ended without final blank line)
141
+ if (buf.trim().length) {
142
+ const lines = buf.split(/\r?\n/);
143
+ currEvent = null;
144
+ dataLines = [];
145
+ for (const line of lines) {
146
+ if (!line || line[0] === ":") continue;
147
+ const c = line.indexOf(":");
148
+ const field = (c === -1 ? line : line.slice(0, c)).trim();
149
+ const val = (c === -1 ? "" : line.slice(c + 1)).replace(/^\s/, "");
150
+ if (field === "event") currEvent = val;
151
+ else if (field === "data") dataLines.push(val);
152
+ }
153
+ const outcome = handleBlock();
154
+ if (outcome?.type === "complete") return outcome.json;
155
+ if (outcome?.type === "error") {
156
+ const msg = typeof outcome.detail === "string" ? outcome.detail : JSON.stringify(outcome.detail);
157
+ throw new Error(`SSE error event: ${abbrev(msg ?? "null", 1200)}`);
158
+ }
159
  }
160
+
161
+ return null;
162
  }
163
 
164
+ /** v5 call API: POST /gradio_api/call/<fn> → {event_id} → GET /gradio_api/call/<fn>/<event_id> (SSE complete). */
165
  async function gradioCallV5(base: string, fnName: string, dataArray: any[]) {
166
  const callUrl = `${base}/gradio_api/call/${fnName}`;
167
 
168
  const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
169
  method: "POST",
170
+ headers: {
171
+ "Content-Type": "application/json",
172
+ Accept: "application/json",
173
+ ...authHeaders(),
174
+ },
175
  body: JSON.stringify({ data: dataArray }),
176
  cache: "no-store",
177
  });
178
 
179
  const postText = await postRes.text();
180
+ const postJson = (() => {
181
+ try {
182
+ return JSON.parse(stripToJson(postText));
183
+ } catch {
184
+ return null;
185
+ }
186
+ })();
187
 
188
  if (!postRes.ok) {
189
  const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postText ?? "(empty body)";
190
  throw new Error(`POST ${callUrl} → ${postRes.status} in ${postMs}ms — ${abbrev(detail)}`);
191
  }
192
+
193
  const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
194
  if (!eventId) throw new Error(`POST ${callUrl} returned no event_id`);
195
 
196
  const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
197
  const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
198
  method: "GET",
199
+ headers: {
200
+ Accept: "text/event-stream",
201
+ Connection: "keep-alive",
202
+ ...authHeaders(),
203
+ },
204
+ cache: "no-store",
205
  });
206
+
207
  if (getRes.status !== 200) {
208
  const txt = await getRes.text();
209
  throw new Error(`GET ${getUrl} → ${getRes.status} in ${getMs}ms — ${abbrev(txt)}`);
210
  }
211
 
212
+ const json = await readSSEComplete(getRes);
213
  if (json == null) {
214
+ // Gradio sometimes sends event:error with data:null
215
  throw new Error(`GET ${getUrl} returned no payload (SSE error/null).`);
216
  }
217
  return json;
 
224
  try {
225
  const { res, ms } = await timedFetch(url, {
226
  method: "POST",
227
+ headers: {
228
+ "Content-Type": "application/json",
229
+ Accept: "application/json",
230
+ ...authHeaders(),
231
+ },
232
  body: JSON.stringify(body),
233
  cache: "no-store",
234
  });
235
+
236
  const txt = await res.text();
237
+ const json = (() => {
238
+ try {
239
+ return JSON.parse(stripToJson(txt));
240
+ } catch {
241
+ return null;
242
+ }
243
+ })();
244
+
245
  if (res.ok) return json;
246
  if (DEBUG) console.warn(`Legacy ${url} → ${res.status} in ${ms}ms — ${abbrev(txt)}`);
247
  } catch (e) {
 
254
  /** Normalize various Gradio payload shapes into StoryLine[] */
255
  function extractStoryLines(payload: any): StoryLine[] {
256
  let d = payload?.data ?? payload;
257
+ if (Array.isArray(d) && Array.isArray(d[0])) d = d[0];
258
  if (!Array.isArray(d)) throw new Error(`Unexpected response: ${abbrev(payload)}`);
259
+
260
+ return d.map((l: any): StoryLine => ({
261
+ text: String(l?.text ?? "")
 
262
  .replaceAll(" .", ".")
263
  .replaceAll(" ,", ",")
264
  .replaceAll(" !", "!")
 
275
  throw new Error("prompt is too short!");
276
  }
277
  const cropped = prompt.slice(0, 30);
278
+ if (DEBUG) console.log(`user requested "${cropped}${cropped !== prompt ? "..." : ""}"`);
279
 
280
+ // 1) Preferred: Gradio v5 call API (SSE complete)
281
  try {
282
  const json = await gradioCallV5(BASE, "predict", [SECRET, prompt, voice]);
283
  return extractStoryLines(json);
284
  } catch (e) {
 
 
285
  console.warn("Gradio v5 call failed:", (e as any)?.message || e);
286
  }
287
 
288
+ // 2) Fallback: legacy JSON endpoints
289
  const legacy = await gradioPredictLegacy(BASE, { fn_index: LEGACY_FN_INDEX, data: [SECRET, prompt, voice] });
290
  return extractStoryLines(legacy);
291
  }
tests/test_connections/test_endpoints.mjs CHANGED
@@ -7,86 +7,135 @@ const STORY_SECRET = process.env.AI_STORY_API_SECRET_TOKEN || '';
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) */
@@ -99,13 +148,19 @@ async function tryCallApi(base, fnName, dataArray) {
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)';
@@ -123,7 +178,8 @@ async function tryCallApi(base, fnName, dataArray) {
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) {
@@ -131,8 +187,8 @@ async function tryCallApi(base, fnName, dataArray) {
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
  }
@@ -148,24 +204,26 @@ async function tryPredictLegacy(base, body) {
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 };
@@ -186,9 +244,9 @@ async function testStoryEndpoint() {
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);
@@ -204,12 +262,11 @@ async function testStoryEndpoint() {
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);
@@ -222,7 +279,7 @@ async function testStoryEndpoint() {
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
 
@@ -249,7 +306,7 @@ async function testImageEndpoint() {
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.');
@@ -266,7 +323,7 @@ async function testImageEndpoint() {
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 () => {
@@ -279,4 +336,4 @@ async function testImageEndpoint() {
279
  console.error('\nTests failed ❌:', e?.message || e);
280
  process.exit(1);
281
  }
282
- })();
 
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 HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || '';
11
+
12
  const FN_INDEX = Number(process.env.AI_STORY_API_FN_INDEX ?? 0); // legacy only
13
  const DEBUG = (process.env.DEBUG_STORY_API || '').toLowerCase() === 'true';
14
 
15
  function abbrev(s, n = 1800) {
16
  if (s == null) return String(s);
17
+ const str = typeof s === 'string' ? s : JSON.stringify(s);
18
+ return str.length > n ? str.slice(0, n) + '…' : str;
19
  }
20
+
21
  function assertEnv() {
22
  if (!STORY_BASE) throw new Error('Missing AI_STORY_API_GRADIO_URL in .env');
23
  if (!STORY_SECRET) throw new Error('Missing AI_STORY_API_SECRET_TOKEN in .env');
24
  }
25
+
26
+ function authHeaders() {
27
+ const h = {};
28
+ if (HF_TOKEN) h.Authorization = `Bearer ${HF_TOKEN}`;
29
+ return h;
30
+ }
31
+
32
  async function withTimeout(promise, ms = 120_000) {
33
  return Promise.race([
34
  promise,
35
  new Promise((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)),
36
  ]);
37
  }
38
+
39
  async function timedFetch(url, init) {
40
  const t0 = Date.now();
41
  const res = await withTimeout(fetch(url, init));
42
  const ms = Date.now() - t0;
43
  return { res, ms };
44
  }
45
+
46
  function stripToJson(text) {
47
  if (!text) return '';
48
+ const ixBrace = text.indexOf('{');
49
+ const ixBracket = text.indexOf('[');
50
+ const pick = [ixBrace, ixBracket].filter((i) => i >= 0);
51
+ if (!pick.length) return text;
52
+ const i = Math.min(...pick);
53
+ return text.slice(i);
 
54
  }
55
 
56
+ /** Parse SSE and return the JSON payload of the **complete** event (throw on error). */
57
+ async function readSSEComplete(res, { maxMs = 120_000 } = {}) {
58
  const ct = (res.headers.get('content-type') || '').toLowerCase();
59
+ // Some deployments reply JSON directly (no SSE) — accept that.
60
  if (ct.includes('application/json')) {
61
  const txt = await res.text();
62
+ try {
63
+ return JSON.parse(stripToJson(txt));
64
+ } catch {
65
+ return null;
66
+ }
67
  }
68
 
69
+ // Proper SSE parse: look for event: complete / event: error
70
  const reader = res.body.getReader();
71
  const decoder = new TextDecoder();
72
  let buf = '';
73
+ let currEvent = null;
74
+ let dataLines = [];
75
+ const started = Date.now();
76
+
77
+ const handleBlock = () => {
78
+ if (!currEvent) return;
79
+ const payloadText = dataLines.join('\n');
80
+ if (DEBUG) console.log(` [SSE event=${currEvent}] ${abbrev(payloadText, 600)}`);
81
+ if (currEvent === 'complete') {
82
+ if (!payloadText) return { type: 'complete', json: null };
83
+ try {
84
+ return { type: 'complete', json: JSON.parse(stripToJson(payloadText)) };
85
+ } catch {
86
+ return { type: 'complete', json: null };
 
 
 
 
 
87
  }
88
+ }
89
+ if (currEvent === 'error') {
90
+ let detail = null;
91
+ try {
92
+ detail = payloadText ? JSON.parse(stripToJson(payloadText)) : null;
93
+ } catch {
94
+ detail = payloadText;
95
  }
96
+ return { type: 'error', detail };
97
  }
98
+ return undefined;
99
  };
100
 
101
  while (true) {
102
+ if (Date.now() - started > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
103
  const { value, done } = await reader.read();
104
  if (done) break;
105
+ buf += decoder.decode(value, { stream: true });
106
+
107
+ // Normalize CRLF and split into blocks
108
+ let idx;
109
+ while ((idx = buf.search(/\r?\n\r?\n/)) !== -1) {
110
+ const raw = buf.slice(0, idx);
111
+ buf = buf.slice(idx).replace(/^\r?\n/, '');
112
+ const lines = raw.split(/\r?\n/);
113
+
114
+ // Parse fields inside this SSE block
115
+ currEvent = null;
116
+ dataLines = [];
117
+ for (const line of lines) {
118
+ if (!line) continue;
119
+ // comment keep-alive lines start with ':'
120
+ if (line[0] === ':') continue;
121
+ const c = line.indexOf(':');
122
+ const field = (c === -1 ? line : line.slice(0, c)).trim();
123
+ const val = (c === -1 ? '' : line.slice(c + 1)).replace(/^\s/, '');
124
+ if (field === 'event') currEvent = val;
125
+ else if (field === 'data') dataLines.push(val);
126
+ }
127
+
128
+ const outcome = handleBlock();
129
+ if (outcome?.type === 'complete') return outcome.json;
130
+ if (outcome?.type === 'error') {
131
+ const msg = typeof outcome.detail === 'string'
132
+ ? outcome.detail
133
+ : JSON.stringify(outcome.detail);
134
+ throw new Error(`SSE error event: ${abbrev(msg ?? 'null', 1200)}`);
135
+ }
136
+ }
137
  }
138
+ return null;
139
  }
140
 
141
  /** Gradio v5: POST /gradio_api/call/<fn> -> {event_id}, then GET /gradio_api/call/<fn>/<event_id> (SSE) */
 
148
 
149
  const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
150
  method: 'POST',
151
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...authHeaders() },
152
  body: JSON.stringify(postBody),
153
  cache: 'no-store',
154
  });
155
 
156
  const postText = await postRes.text();
157
+ const postJson = (() => {
158
+ try {
159
+ return JSON.parse(stripToJson(postText));
160
+ } catch {
161
+ return null;
162
+ }
163
+ })();
164
 
165
  if (!postRes.ok) {
166
  const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postText ?? '(empty body)';
 
178
 
179
  const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
180
  method: 'GET',
181
+ headers: { Accept: 'text/event-stream', ...authHeaders() },
182
+ cache: 'no-store',
183
  });
184
 
185
  if (getRes.status !== 200) {
 
187
  throw new Error(`HTTP ${getRes.status} ${getRes.statusText} in ${getMs}ms — ${abbrev(txt, 800)}`);
188
  }
189
 
190
+ const json = await readSSEComplete(getRes);
191
+ console.log(` ✅ OK 200 in ${getMs}ms`);
192
  if (DEBUG) console.log(' json:', abbrev(JSON.stringify(json), 2000));
193
  return { ok: true, url: getUrl, json, ms: getMs };
194
  }
 
204
 
205
  const { res, ms } = await timedFetch(url, {
206
  method: 'POST',
207
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...authHeaders() },
208
  body: JSON.stringify(body),
209
  cache: 'no-store',
210
  });
211
 
212
  const text = await res.text();
213
  let json = null;
214
+ try {
215
+ json = JSON.parse(stripToJson(text));
216
+ } catch {}
217
  if (res.ok) {
218
+ console.log(` ✅ OK ${res.status} in ${ms}ms`);
219
  if (DEBUG) console.log(' json:', abbrev(JSON.stringify(json), 2000));
220
  return { ok: true, url, json, ms };
221
  }
222
 
223
  const detail = json?.detail ?? json?.error ?? json?.message ?? text ?? '(empty body)';
224
+ console.error(` ❌ HTTP ${res.status} ${res.statusText} in ${ms}ms — ${abbrev(String(detail), 1200)}`);
225
  } catch (e) {
226
+ console.error(` ❌ ${url} failed: ${e?.message || e}`);
227
  }
228
  }
229
  return { ok: false };
 
244
  console.log('TOKEN:', STORY_SECRET ? '(present)' : '(MISSING)');
245
  console.log('FN :', FN_INDEX);
246
 
247
+ // HEAD liveness (with auth if provided)
248
  try {
249
+ const { res, ms } = await timedFetch(STORY_BASE + '/', { method: 'HEAD', headers: authHeaders() });
250
  console.log(`HEAD ${STORY_BASE}/ -> ${res.status} in ${ms}ms`);
251
  } catch (e) {
252
  console.warn('HEAD failed:', e?.message || e);
 
262
 
263
  const lines = extractStoryLines(out.json);
264
  if (Array.isArray(lines)) {
265
+ console.log(` ✅ Received ${lines.length} story lines`);
266
  return;
267
  }
268
 
269
+ console.warn('⚠️ Story endpoint returned no recognized payload (SSE complete missing/empty). Treating as reachable.');
 
270
  return;
271
  } catch (e) {
272
  console.warn('Gradio v5 call API failed for story:', e?.message || e);
 
279
  if (!Array.isArray(lines)) {
280
  console.warn('⚠️ Legacy story payload not recognized, but endpoint responded. Treating as reachable.');
281
  } else {
282
+ console.log(` ✅ Legacy story returned ${lines.length} lines`);
283
  }
284
  }
285
 
 
306
  (payload && typeof payload === 'object' && ('url' in payload || 'path' in payload));
307
 
308
  if (ok) {
309
+ console.log(' ✅ Image endpoint responded.');
310
  return;
311
  }
312
  console.warn('⚠️ Image payload not recognized, but endpoint responded. Treating as reachable.');
 
323
  Array.isArray(payload) ||
324
  (payload && typeof payload === 'object' && ('url' in payload || 'path' in payload));
325
  if (!ok) console.warn('⚠️ Legacy image payload not recognized, but endpoint responded. Treating as reachable.');
326
+ else console.log(' ✅ Legacy image endpoint responded.');
327
  }
328
 
329
  (async () => {
 
336
  console.error('\nTests failed ❌:', e?.message || e);
337
  process.exit(1);
338
  }
339
+ })();
tests/test_connections/test_endpoints.sh CHANGED
@@ -22,6 +22,12 @@ 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
 
@@ -41,28 +47,75 @@ strip_to_json() {
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 "--------------------------------"
@@ -73,34 +126,44 @@ try_call_api() {
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
 
22
  VOICE="${2:-Cloée}"
23
  IMG_PROMPT="${3:-a cute cat sticker}"
24
 
25
+ # Optional Bearer (for private Space / ZeroGPU quota)
26
+ AUTH_HEADER=()
27
+ if [[ -n "${HF_TOKEN:-}" ]]; then
28
+ AUTH_HEADER=(-H "Authorization: Bearer ${HF_TOKEN}")
29
+ fi
30
+
31
  has_jq() { command -v jq >/dev/null 2>&1; }
32
  pretty() { if has_jq; then jq . || cat; else cat; fi; }
33
 
 
47
  post_json() {
48
  local url="$1" json="$2"
49
  echo "POST $url" >&2
50
+ curl -sS -H "Content-Type: application/json" "${AUTH_HEADER[@]}" \
51
+ -X POST "$url" -d "$json" -w "\nHTTP_STATUS:%{http_code}"
52
  }
53
 
54
  # GET helper (returns BODY + trailing HTTP_STATUS line)
55
  get_url() {
56
  local url="$1"
57
  echo "GET $url" >&2
58
+ curl -sS "${AUTH_HEADER[@]}" "$url" -w "\nHTTP_STATUS:%{http_code}"
59
  }
60
 
61
+ # --- Parse a Gradio SSE GET stream and echo the `complete` JSON object ---
62
+ # Returns 0 on success and prints the JSON object to stdout.
63
+ # Returns 1 on SSE `error` (prints details to stderr if present).
64
+ # Returns 2 on malformed/unknown stream.
65
+ parse_sse_complete() {
66
+ local stream_file="$1"
67
+ local event="" data_buf="" complete_json="" error_json=""
68
+
69
+ # Normalize CRLF in stream (if any)
70
+ sed -i 's/\r$//' "$stream_file"
71
+
72
+ while IFS= read -r line || [[ -n "$line" ]]; do
73
+ if [[ "$line" == event:* ]]; then
74
+ event="${line#event: }"
75
+ continue
76
+ fi
77
+ if [[ "$line" == data:* ]]; then
78
+ local d="${line#data: }"
79
+ if [[ -z "$data_buf" ]]; then data_buf="$d"; else data_buf+=$'\n'"$d"; fi
80
+ continue
81
+ fi
82
+ # blank line = event separator
83
+ if [[ -z "$line" ]]; then
84
+ if [[ "$event" == "complete" ]]; then
85
+ complete_json="$data_buf"; break
86
+ elif [[ "$event" == "error" ]]; then
87
+ error_json="$data_buf"; break
88
+ fi
89
+ event=""; data_buf=""
90
+ fi
91
+ done < "$stream_file"
92
+
93
+ if [[ -n "$complete_json" ]]; then
94
+ printf '%s' "$complete_json"
95
+ return 0
96
+ fi
97
+ if [[ -n "$error_json" && "$error_json" != "null" ]]; then
98
+ echo "SSE error event:" >&2
99
+ echo "$error_json" >&2
100
+ return 1
101
+ fi
102
+
103
+ echo "SSE stream had no complete/error event. Raw stream follows:" >&2
104
+ cat "$stream_file" >&2
105
+ return 2
106
+ }
107
+
108
+ # New Gradio v4/v5 call API: POST -> event_id -> GET SSE -> complete JSON
109
  try_call_api() {
110
  local base="$1" func="$2" json="$3"
111
  local call_url="${base%/}/gradio_api/call/$func"
112
+ local out status body body_json event_id get_url_full stream_file comp_json
113
 
114
  out="$(post_json "$call_url" "$json" || true)"
115
  status="${out##*HTTP_STATUS:}"
116
  body="${out%HTTP_STATUS:*}"
117
  body_json="$(printf '%s' "$body" | strip_to_json)"
118
 
 
119
  echo "---- ${func}: POST response ----"
120
  printf '%s\n' "$body_json" | pretty
121
  echo "--------------------------------"
 
126
  return 1
127
  fi
128
 
129
+ # Parse event_id
130
  if has_jq; then
131
  event_id="$(printf '%s' "$body_json" | jq -r '.event_id // .eventId // .eventID // empty' 2>/dev/null || echo "")"
132
  else
133
  event_id="$(printf '%s' "$body_json" | sed -n 's/.*"event_id"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')"
134
  fi
 
135
  if [[ -z "${event_id:-}" ]]; then
136
  echo "-> Missing event_id in response." >&2
137
  return 1
138
  fi
139
 
140
+ # GET SSE
141
  get_url_full="${base%/}/gradio_api/call/$func/$event_id"
142
+ stream_file="$(mktemp)"
143
+ echo "GET $get_url_full" >&2
144
+ curl -sS -N -H "Accept: text/event-stream" "${AUTH_HEADER[@]}" \
145
+ "$get_url_full" > "$stream_file"
146
+
147
+ # Parse SSE to the complete event JSON object
148
+ if ! comp_json="$(parse_sse_complete "$stream_file")"; then
149
+ rm -f "$stream_file"
 
 
 
 
 
150
  return 1
151
  fi
152
+ rm -f "$stream_file"
153
+
154
+ # Show full `complete` JSON (wrapper includes {"data":[ ... ], ...})
155
+ echo "---- ${func}: GET (complete event) ----"
156
+ printf '%s\n' "$comp_json" | pretty
157
+ echo "--------------------------------------"
158
+
159
+ # If this is the story function, also print ONLY the story component (.data[0])
160
+ if [[ "$base" == "$STORY_BASE" && "$func" == "predict" ]]; then
161
+ if has_jq && jq -e '.data | type == "array" and (.data | length) > 0' >/dev/null 2>&1 <<<"$comp_json"; then
162
+ echo "---- story data (.data[0]) ----"
163
+ jq -r '.data[0]' <<<"$comp_json"
164
+ echo "-------------------------------"
165
+ fi
166
+ fi
167
 
168
  echo "-> 200 OK (call API)"
169
  return 0
tests/test_connections/test_story.sh ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # tests/test_connections/test_story.sh
3
+ # One-file cURL client for ruslanmv/ai-story-server-cpu -> /predict (SSE aware)
4
+ # Loads .env automatically from repo root if present.
5
+
6
+ set -euo pipefail
7
+
8
+ # Optional debug
9
+ [[ "${DEBUG:-}" == "1" ]] && set -x
10
+
11
+ # Ensure UTF-8 (for "Cloée")
12
+ export LANG="${LANG:-C.UTF-8}"
13
+ export LC_ALL="${LC_ALL:-C.UTF-8}"
14
+
15
+ # --- requirements ---
16
+ for dep in curl jq; do
17
+ command -v "$dep" >/dev/null 2>&1 || { echo "Error: $dep is required." >&2; exit 1; }
18
+ done
19
+
20
+ # --- load .env from repo root (two levels up from this script) ---
21
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
22
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd -P)"
23
+ ENV_FILE="${ENV_FILE:-${REPO_ROOT}/.env}"
24
+ if [[ -f "$ENV_FILE" ]]; then
25
+ set -a
26
+ # shellcheck disable=SC1090
27
+ . "$ENV_FILE"
28
+ set +a
29
+ fi
30
+
31
+ # --- config (env > .env > defaults) ---
32
+ SPACE_URL="${SPACE_URL:-${AI_STORY_API_GRADIO_URL:-https://ruslanmv-ai-story-server-cpu.hf.space}}"
33
+ API_PATH="/gradio_api/call/predict"
34
+
35
+ SECRET="${SECRET_TOKEN:-${AI_STORY_API_SECRET_TOKEN:-secret}}"
36
+ PROMPT="${STORY_PROMPT:-${1:-A cozy bedtime story about a friendly dragon who learns to paint}}"
37
+ ROLE="${STORY_ROLE:-${2:-Cloée}}"
38
+
39
+ case "$ROLE" in
40
+ "Cloée"|"Julian"|"Pirate"|"Thera") ;;
41
+ *) echo "Error: STORY_ROLE must be one of: Cloée, Julian, Pirate, Thera" >&2; exit 1;;
42
+ esac
43
+
44
+ AUTH_HEADER=()
45
+ [[ -n "${HF_TOKEN:-}" ]] && AUTH_HEADER+=(-H "Authorization: Bearer ${HF_TOKEN}")
46
+
47
+ # --- body (order matters: [secret, prompt, role]) ---
48
+ POST_BODY="$(jq -cn --arg s "$SECRET" --arg p "$PROMPT" --arg r "$ROLE" '{data: [$s, $p, $r]}')"
49
+
50
+ # --- temp files ---
51
+ POST_OUT="$(mktemp)"
52
+ STREAM_OUT="$(mktemp)"
53
+ cleanup() { rm -f "$POST_OUT" "$STREAM_OUT"; }
54
+ trap cleanup EXIT
55
+
56
+ # --- 1) POST -> get event_id ---
57
+ HTTP_CODE="$(curl -sS -o "$POST_OUT" -w "%{http_code}" \
58
+ -X POST "${SPACE_URL}${API_PATH}" \
59
+ -H "Content-Type: application/json" "${AUTH_HEADER[@]}" \
60
+ -d "$POST_BODY")"
61
+
62
+ if [[ "$HTTP_CODE" != "200" ]]; then
63
+ echo "Error: POST returned HTTP $HTTP_CODE" >&2
64
+ cat "$POST_OUT" >&2
65
+ exit 3
66
+ fi
67
+ if ! jq -e '.event_id' "$POST_OUT" >/dev/null 2>&1; then
68
+ echo "Error: POST did not return JSON with .event_id" >&2
69
+ cat "$POST_OUT" >&2
70
+ exit 3
71
+ fi
72
+ EVENT_ID="$(jq -r '.event_id' "$POST_OUT")"
73
+
74
+ # --- 2) GET SSE stream (single attempt) ---
75
+ curl -sS -N "${SPACE_URL}${API_PATH}/${EVENT_ID}" \
76
+ -H "Accept: text/event-stream" "${AUTH_HEADER[@]}" > "$STREAM_OUT"
77
+
78
+ # --- 3) Parse SSE in pure bash (no awk) ---
79
+ event=""
80
+ data_buf=""
81
+ complete_json=""
82
+ error_json=""
83
+
84
+ while IFS= read -r line || [[ -n "$line" ]]; do
85
+ if [[ "$line" == event:* ]]; then
86
+ event="${line#event: }"
87
+ continue
88
+ fi
89
+ if [[ "$line" == data:* ]]; then
90
+ d="${line#data: }"
91
+ if [[ -z "$data_buf" ]]; then data_buf="$d"; else data_buf+=$'\n'"$d"; fi
92
+ continue
93
+ fi
94
+ # blank line = event separator
95
+ if [[ -z "$line" ]]; then
96
+ if [[ "$event" == "complete" ]]; then
97
+ complete_json="$data_buf"; break
98
+ elif [[ "$event" == "error" ]]; then
99
+ error_json="$data_buf"; break
100
+ fi
101
+ event=""; data_buf=""
102
+ fi
103
+ done < "$STREAM_OUT"
104
+
105
+ # --- 4) Decide outcome ---
106
+ if [[ -n "$complete_json" ]]; then
107
+ if ! jq -e '.data | type == "array" and (.data | length) > 0' >/dev/null 2>&1 <<<"$complete_json"; then
108
+ echo "Error: Unexpected JSON from complete event:" >&2
109
+ echo "$complete_json" >&2
110
+ exit 2
111
+ fi
112
+ jq -r '.data[0]' <<<"$complete_json"
113
+ exit 0
114
+ fi
115
+
116
+ # error or nothing
117
+ if [[ -n "$error_json" && "$error_json" != "null" ]]; then
118
+ echo "Error: Server error:" >&2
119
+ echo "$error_json" >&2
120
+ else
121
+ echo "Error: Space returned SSE error with no details (likely ZeroGPU quota or invalid secret)." >&2
122
+ echo "Hints: verify SECRET, set HF_TOKEN (loaded from .env if present), or try later." >&2
123
+ fi
124
+ exit 1