Skip to content

Commit c2f3f39

Browse files
CharlieHelpsjchris
authored andcommitted
fix: improve AI key selection and image handling
- Add typed list of AI API key env vars and iterate to pick first available - Remove Node Buffer-based base64 fallback and require runtime `atob` support - Simplify tests by dropping global `atob` polyfill setup/teardown - Detect image content type (PNG/JPEG/GIF/WebP) from binary data in renderApp - Use detected content type for full and ranged image responses; fix 416 to return text/plain - Preserve existing app summaries/icons when present and only generate missing ones - Harden PublishedVibeCard image load/error handlers to avoid source flip loops and rely on event `src`
1 parent 5ed0f5b commit c2f3f39

File tree

5 files changed

+129
-87
lines changed

5 files changed

+129
-87
lines changed

hosting/pkg/src/endpoints/appCreate.ts

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { App, PublishEvent } from "../types.js";
66
import { generateVibeSlug } from "@vibes.diy/hosting-base";
77
import { callAI, imageGen } from "call-ai";
88

9+
const AI_API_KEY_ENV_VARS = [
10+
"CALLAI_API_KEY",
11+
"OPENROUTER_API_KEY",
12+
"SERVER_OPENROUTER_API_KEY",
13+
] as const;
14+
15+
type AiApiKeyEnvVar = (typeof AI_API_KEY_ENV_VARS)[number];
16+
917
// Variables type for context (was previously in deleted auth middleware)
1018
interface Variables {
1119
user: { sub?: string; userId?: string; email?: string } | null;
@@ -44,40 +52,40 @@ async function processScreenshot(
4452
}
4553

4654
function getCallAiApiKey(env: Env): string | undefined {
47-
const typedEnv = env as Env & {
48-
CALLAI_API_KEY?: string;
49-
OPENROUTER_API_KEY?: string;
50-
SERVER_OPENROUTER_API_KEY?: string;
51-
};
55+
const typedEnv = env as Env & Record<AiApiKeyEnvVar, string | undefined>;
5256

53-
return (
54-
typedEnv.CALLAI_API_KEY ||
55-
typedEnv.OPENROUTER_API_KEY ||
56-
typedEnv.SERVER_OPENROUTER_API_KEY
57-
);
57+
for (const key of AI_API_KEY_ENV_VARS) {
58+
const value = typedEnv[key];
59+
if (value) return value;
60+
}
61+
62+
return undefined;
5863
}
5964

6065
function base64ToArrayBuffer(base64Data: string) {
61-
if (typeof atob === "function") {
62-
const binaryData = atob(base64Data);
63-
const len = binaryData.length;
64-
const bytes = new Uint8Array(len);
65-
for (let i = 0; i < len; i++) {
66-
bytes[i] = binaryData.charCodeAt(i);
67-
}
68-
return bytes.buffer;
66+
if (typeof atob !== "function") {
67+
throw new Error(
68+
"base64ToArrayBuffer: atob is not available in this runtime",
69+
);
6970
}
7071

71-
const buffer = Buffer.from(base64Data, "base64");
72-
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
72+
const binaryData = atob(base64Data);
73+
const len = binaryData.length;
74+
const bytes = new Uint8Array(len);
75+
for (let i = 0; i < len; i++) {
76+
bytes[i] = binaryData.charCodeAt(i);
77+
}
78+
return bytes.buffer;
7379
}
7480

7581
async function generateAppSummary(
7682
app: z.infer<typeof App>,
7783
apiKey?: string,
7884
): Promise<string | null> {
7985
if (!apiKey) {
80-
console.warn("⚠️ CALLAI_API_KEY not set - skipping app summary generation");
86+
console.warn(
87+
`⚠️ AI API key not set (${AI_API_KEY_ENV_VARS.join(", ")}) - skipping app summary generation`,
88+
);
8189
return null;
8290
}
8391

@@ -114,7 +122,9 @@ async function generateAppIcon(
114122
apiKey?: string,
115123
): Promise<string | null> {
116124
if (!apiKey) {
117-
console.warn("⚠️ CALLAI_API_KEY not set - skipping app icon generation");
125+
console.warn(
126+
`⚠️ AI API key not set (${AI_API_KEY_ENV_VARS.join(", ")}) - skipping app icon generation`,
127+
);
118128
return null;
119129
}
120130

@@ -332,15 +342,23 @@ export class AppCreate extends OpenAPIRoute {
332342
savedApp = appToSave;
333343
}
334344

335-
const summary = await generateAppSummary(savedApp, callAiApiKey);
336-
if (summary) {
337-
savedApp.summary = summary;
345+
const hasSummary =
346+
typeof savedApp.summary === "string" &&
347+
savedApp.summary.trim().length > 0;
348+
if (!hasSummary) {
349+
const summary = await generateAppSummary(savedApp, callAiApiKey);
350+
if (summary) {
351+
savedApp.summary = summary;
352+
}
338353
}
339354

340-
const iconKey = await generateAppIcon(savedApp, kv, callAiApiKey);
341-
if (iconKey) {
342-
savedApp.iconKey = iconKey;
343-
savedApp.hasIcon = true;
355+
const hasIcon = Boolean(savedApp.hasIcon && savedApp.iconKey);
356+
if (!hasIcon) {
357+
const iconKey = await generateAppIcon(savedApp, kv, callAiApiKey);
358+
if (iconKey) {
359+
savedApp.iconKey = iconKey;
360+
savedApp.hasIcon = true;
361+
}
344362
}
345363

346364
// Persist any AI-enriched fields

hosting/pkg/src/renderApp.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,61 @@ function parseRangeHeader(
224224
return { start, end };
225225
}
226226

227+
function detectImageContentType(buffer: ArrayBuffer): string {
228+
const bytes = new Uint8Array(buffer);
229+
230+
if (
231+
bytes.length >= 8 &&
232+
bytes[0] === 0x89 &&
233+
bytes[1] === 0x50 &&
234+
bytes[2] === 0x4e &&
235+
bytes[3] === 0x47 &&
236+
bytes[4] === 0x0d &&
237+
bytes[5] === 0x0a &&
238+
bytes[6] === 0x1a &&
239+
bytes[7] === 0x0a
240+
) {
241+
return "image/png";
242+
}
243+
244+
if (
245+
bytes.length >= 3 &&
246+
bytes[0] === 0xff &&
247+
bytes[1] === 0xd8 &&
248+
bytes[2] === 0xff
249+
) {
250+
return "image/jpeg";
251+
}
252+
253+
if (
254+
bytes.length >= 6 &&
255+
bytes[0] === 0x47 &&
256+
bytes[1] === 0x49 &&
257+
bytes[2] === 0x46 &&
258+
bytes[3] === 0x38 &&
259+
(bytes[4] === 0x39 || bytes[4] === 0x37) &&
260+
bytes[5] === 0x61
261+
) {
262+
return "image/gif";
263+
}
264+
265+
if (
266+
bytes.length >= 12 &&
267+
bytes[0] === 0x52 && // R
268+
bytes[1] === 0x49 && // I
269+
bytes[2] === 0x46 && // F
270+
bytes[3] === 0x46 && // F
271+
bytes[8] === 0x57 && // W
272+
bytes[9] === 0x45 && // E
273+
bytes[10] === 0x42 && // B
274+
bytes[11] === 0x50 // P
275+
) {
276+
return "image/webp";
277+
}
278+
279+
return "application/octet-stream";
280+
}
281+
227282
// Shared image asset handler logic (screenshots/icons)
228283
async function handleImageRequest(
229284
c: Context,
@@ -285,6 +340,7 @@ async function handleImageRequest(
285340
}
286341

287342
const fileSize = asset.byteLength;
343+
const contentType = detectImageContentType(asset);
288344
const rangeHeader = c.req.header("Range");
289345

290346
// Handle Range requests
@@ -297,7 +353,7 @@ async function handleImageRequest(
297353
status: 416,
298354
headers: {
299355
"Content-Range": `bytes */${fileSize}`,
300-
"Content-Type": "image/png",
356+
"Content-Type": "text/plain; charset=utf-8",
301357
},
302358
});
303359
}
@@ -307,7 +363,7 @@ async function handleImageRequest(
307363
const chunk = asset.slice(start, end + 1);
308364

309365
const headers = {
310-
"Content-Type": "image/png",
366+
"Content-Type": contentType,
311367
"Content-Length": contentLength.toString(),
312368
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
313369
"Accept-Ranges": "bytes",
@@ -323,7 +379,7 @@ async function handleImageRequest(
323379

324380
// Standard GET/HEAD request - return full file
325381
const headers = {
326-
"Content-Type": "image/png",
382+
"Content-Type": contentType,
327383
"Content-Length": fileSize.toString(),
328384
"Accept-Ranges": "bytes",
329385
"Cache-Control": "public, max-age=86400",

hosting/tests/unit/endpoints/appCreate.test.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
import {
2-
describe,
3-
it,
4-
expect,
5-
beforeEach,
6-
afterEach,
7-
beforeAll,
8-
afterAll,
9-
vi,
10-
} from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
112
import { AppCreate } from "@vibes.diy/hosting";
123
import { OpenAPIRoute } from "chanfana";
134

@@ -23,20 +14,6 @@ vi.mock("call-ai", () => {
2314
};
2415
});
2516

26-
const originalAtob = global.atob;
27-
28-
beforeAll(() => {
29-
if (typeof global.atob !== "function") {
30-
global.atob = (data: string) => Buffer.from(data, "base64").toString("binary");
31-
}
32-
});
33-
34-
afterAll(() => {
35-
if (originalAtob) {
36-
global.atob = originalAtob;
37-
}
38-
});
39-
4017
describe("AppCreate endpoint", () => {
4118
let originalFetch: typeof global.fetch;
4219
let mockFetch: typeof global.fetch;

hosting/tests/unit/queue.test.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
import {
2-
describe,
3-
it,
4-
expect,
5-
beforeEach,
6-
afterEach,
7-
beforeAll,
8-
afterAll,
9-
vi,
10-
} from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
112
import { AppCreate, PublishEvent } from "@vibes.diy/hosting";
123
import type { OpenAPIRoute } from "chanfana";
134

@@ -23,20 +14,6 @@ vi.mock("call-ai", () => {
2314
};
2415
});
2516

26-
const originalAtob = global.atob;
27-
28-
beforeAll(() => {
29-
if (typeof global.atob !== "function") {
30-
global.atob = (data: string) => Buffer.from(data, "base64").toString("binary");
31-
}
32-
});
33-
34-
afterAll(() => {
35-
if (originalAtob) {
36-
global.atob = originalAtob;
37-
}
38-
});
39-
4017
// Mock types
4118
interface MockKV {
4219
get: (key: string, type?: string) => Promise<string | ArrayBuffer | null>;

vibes.diy/pkg/app/components/PublishedVibeCard.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,25 @@ export default function PublishedVibeCard({
2727
setUsingIcon(true);
2828
}, [iconUrl]);
2929

30-
const handleImageError = () => {
31-
if (imageSrc !== screenshotUrl) {
32-
setImageSrc(screenshotUrl);
33-
setUsingIcon(false);
30+
const handleImageError: React.ReactEventHandler<HTMLImageElement> = (
31+
event,
32+
) => {
33+
const failedSrc = event.currentTarget.src;
34+
35+
// If the screenshot also fails, don't loop between sources
36+
if (failedSrc === screenshotUrl) {
37+
return;
3438
}
39+
40+
setImageSrc(screenshotUrl);
41+
setUsingIcon(false);
3542
};
3643

37-
const handleImageLoad = () => {
38-
setUsingIcon(imageSrc === iconUrl);
44+
const handleImageLoad: React.ReactEventHandler<HTMLImageElement> = (
45+
event,
46+
) => {
47+
const loadedSrc = event.currentTarget.src;
48+
setUsingIcon(loadedSrc === iconUrl);
3949
};
4050
const linkUrl = `/vibe/${slug}`;
4151

@@ -82,7 +92,11 @@ export default function PublishedVibeCard({
8292
<div className="relative z-10 flex h-48 w-full justify-center py-2">
8393
<img
8494
src={imageSrc}
85-
alt={usingIcon ? `Icon for ${vibeName}` : `Screenshot from ${vibeName}`}
95+
alt={
96+
usingIcon
97+
? `Icon for ${vibeName}`
98+
: `Screenshot from ${vibeName}`
99+
}
86100
className="max-h-full max-w-full object-contain"
87101
loading="lazy"
88102
onError={handleImageError}

0 commit comments

Comments
 (0)