Skip to content

Commit 5ed0f5b

Browse files
committed
Display vibe icons when available
1 parent a6243cf commit 5ed0f5b

File tree

8 files changed

+287
-34
lines changed

8 files changed

+287
-34
lines changed

hosting/base/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const App = z.object({
1616
email: z.string().nullable().optional(),
1717
hasScreenshot: z.boolean().nullable().optional(),
1818
screenshotKey: z.string().nullable().optional(),
19+
summary: z.string().nullable().optional(),
20+
iconKey: z.string().nullable().optional(),
21+
hasIcon: z.boolean().nullable().optional(),
1922
remixOf: z.string().nullable().optional(),
2023
updateCount: z.number().nullable().optional(),
2124
shareToFirehose: z.boolean().nullable().optional(),

hosting/pkg/src/endpoints/appCreate.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Context } from "hono";
44
import { z } from "zod";
55
import { App, PublishEvent } from "../types.js";
66
import { generateVibeSlug } from "@vibes.diy/hosting-base";
7+
import { callAI, imageGen } from "call-ai";
78

89
// Variables type for context (was previously in deleted auth middleware)
910
interface Variables {
@@ -42,6 +43,102 @@ async function processScreenshot(
4243
}
4344
}
4445

46+
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+
};
52+
53+
return (
54+
typedEnv.CALLAI_API_KEY ||
55+
typedEnv.OPENROUTER_API_KEY ||
56+
typedEnv.SERVER_OPENROUTER_API_KEY
57+
);
58+
}
59+
60+
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;
69+
}
70+
71+
const buffer = Buffer.from(base64Data, "base64");
72+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
73+
}
74+
75+
async function generateAppSummary(
76+
app: z.infer<typeof App>,
77+
apiKey?: string,
78+
): Promise<string | null> {
79+
if (!apiKey) {
80+
console.warn("⚠️ CALLAI_API_KEY not set - skipping app summary generation");
81+
return null;
82+
}
83+
84+
try {
85+
const contextPieces = [
86+
app.title ? `Title: ${app.title}` : null,
87+
app.prompt ? `Prompt: ${app.prompt}` : null,
88+
app.code ? `Code snippet: ${app.code.slice(0, 1000)}` : null,
89+
].filter(Boolean);
90+
91+
const messages = [
92+
{
93+
role: "system" as const,
94+
content:
95+
"You write concise, single-sentence summaries of small web apps. Keep it under 35 words, highlight the main category and what the app helps users do.",
96+
},
97+
{
98+
role: "user" as const,
99+
content: contextPieces.join("\n\n"),
100+
},
101+
];
102+
103+
const response = await callAI(messages, { apiKey });
104+
return typeof response === "string" ? response.trim() : null;
105+
} catch (error) {
106+
console.error("Error generating app summary:", error);
107+
return null;
108+
}
109+
}
110+
111+
async function generateAppIcon(
112+
app: z.infer<typeof App>,
113+
kv: KVNamespace,
114+
apiKey?: string,
115+
): Promise<string | null> {
116+
if (!apiKey) {
117+
console.warn("⚠️ CALLAI_API_KEY not set - skipping app icon generation");
118+
return null;
119+
}
120+
121+
try {
122+
const category = app.title || app.name || "app";
123+
const prompt = `Minimal black icon on a white background, enclosed in a circle, representing ${category}. Use clear, text-free imagery to convey the category. Avoid letters or numbers.`;
124+
const response = await imageGen(prompt, { apiKey, size: "512x512" });
125+
const iconBase64 = response.data?.[0]?.b64_json;
126+
127+
if (!iconBase64) {
128+
console.warn("⚠️ No icon data returned from imageGen");
129+
return null;
130+
}
131+
132+
const iconArrayBuffer = base64ToArrayBuffer(iconBase64);
133+
const iconKey = `${app.slug}-icon`;
134+
await kv.put(iconKey, iconArrayBuffer);
135+
return iconKey;
136+
} catch (error) {
137+
console.error("Error generating app icon:", error);
138+
return null;
139+
}
140+
}
141+
45142
// Request body schema for app creation
46143
const AppCreateRequestSchema = z.object({
47144
chatId: z.string(),
@@ -102,6 +199,7 @@ export class AppCreate extends OpenAPIRoute {
102199

103200
// Get the KV namespace from the context
104201
const kv = c.env.KV;
202+
const callAiApiKey = getCallAiApiKey(c.env);
105203

106204
// Check if the app with this chatId already exists
107205
const existingApp = await kv.get(app.chatId);
@@ -210,6 +308,9 @@ export class AppCreate extends OpenAPIRoute {
210308
title: app.title || `App ${slug}`,
211309
remixOf: app.remixOf === undefined ? null : app.remixOf,
212310
hasScreenshot: false,
311+
summary: null,
312+
iconKey: null,
313+
hasIcon: false,
213314
shareToFirehose: app.shareToFirehose,
214315
customDomain: app.customDomain || null,
215316
};
@@ -231,6 +332,21 @@ export class AppCreate extends OpenAPIRoute {
231332
savedApp = appToSave;
232333
}
233334

335+
const summary = await generateAppSummary(savedApp, callAiApiKey);
336+
if (summary) {
337+
savedApp.summary = summary;
338+
}
339+
340+
const iconKey = await generateAppIcon(savedApp, kv, callAiApiKey);
341+
if (iconKey) {
342+
savedApp.iconKey = iconKey;
343+
savedApp.hasIcon = true;
344+
}
345+
346+
// Persist any AI-enriched fields
347+
await kv.put(savedApp.chatId, JSON.stringify(savedApp));
348+
await kv.put(savedApp.slug, JSON.stringify(savedApp));
349+
234350
// Send event to queue for processing
235351
try {
236352
if (!c.env.PUBLISH_QUEUE) {

hosting/pkg/src/renderApp.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,12 @@ function parseRangeHeader(
224224
return { start, end };
225225
}
226226

227-
// Shared screenshot handler logic
228-
async function handleScreenshotRequest(c: Context, includeBody = true) {
227+
// Shared image asset handler logic (screenshots/icons)
228+
async function handleImageRequest(
229+
c: Context,
230+
keySuffix: string,
231+
includeBody = true,
232+
) {
229233
// Extract subdomain from the request URL
230234
const url = new URL(c.req.url);
231235
const hostname = url.hostname;
@@ -270,17 +274,17 @@ async function handleScreenshotRequest(c: Context, includeBody = true) {
270274
appSlug = parsed.appSlug;
271275
}
272276

273-
// Calculate screenshot key based on app slug (screenshots are always for the base app)
274-
const screenshotKey = `${appSlug}-screenshot`;
277+
// Calculate asset key based on app slug
278+
const assetKey = `${appSlug}-${keySuffix}`;
275279

276-
// Get the screenshot from KV
277-
const screenshot = await kv.get(screenshotKey, "arrayBuffer");
280+
// Get the asset from KV
281+
const asset = await kv.get(assetKey, "arrayBuffer");
278282

279-
if (!screenshot) {
283+
if (!asset) {
280284
return c.notFound();
281285
}
282286

283-
const fileSize = screenshot.byteLength;
287+
const fileSize = asset.byteLength;
284288
const rangeHeader = c.req.header("Range");
285289

286290
// Handle Range requests
@@ -300,7 +304,7 @@ async function handleScreenshotRequest(c: Context, includeBody = true) {
300304

301305
const { start, end } = range;
302306
const contentLength = end - start + 1;
303-
const chunk = screenshot.slice(start, end + 1);
307+
const chunk = asset.slice(start, end + 1);
304308

305309
const headers = {
306310
"Content-Type": "image/png",
@@ -326,17 +330,29 @@ async function handleScreenshotRequest(c: Context, includeBody = true) {
326330
"Access-Control-Allow-Origin": "*",
327331
};
328332

329-
// Return the screenshot with proper headers, optionally including body
330-
return new Response(includeBody ? screenshot : null, { headers });
333+
// Return the asset with proper headers, optionally including body
334+
return new Response(includeBody ? asset : null, { headers });
331335
}
332336

333337
// Route to serve app screenshots as PNG images (GET and HEAD)
334338
app.all("/screenshot.png", async (c) => {
335339
const method = c.req.method;
336340
if (method === "GET") {
337-
return handleScreenshotRequest(c, true);
341+
return handleImageRequest(c, "screenshot", true);
338342
} else if (method === "HEAD") {
339-
return handleScreenshotRequest(c, false);
343+
return handleImageRequest(c, "screenshot", false);
344+
} else {
345+
return c.json({ error: "Method not allowed" }, 405);
346+
}
347+
});
348+
349+
// Route to serve app icons as PNG images (GET and HEAD)
350+
app.all("/icon.png", async (c) => {
351+
const method = c.req.method;
352+
if (method === "GET") {
353+
return handleImageRequest(c, "icon", true);
354+
} else if (method === "HEAD") {
355+
return handleImageRequest(c, "icon", false);
340356
} else {
341357
return c.json({ error: "Method not allowed" }, 405);
342358
}

hosting/pkg/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const App = z.object({
1515
email: z.string().nullable().optional(),
1616
hasScreenshot: z.boolean().nullable().optional(),
1717
screenshotKey: z.string().nullable().optional(),
18+
summary: z.string().nullable().optional(),
19+
iconKey: z.string().nullable().optional(),
20+
hasIcon: z.boolean().nullable().optional(),
1821
remixOf: z.string().nullable().optional(),
1922
updateCount: z.number().nullable().optional(),
2023
shareToFirehose: z.boolean().nullable().optional(),

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

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

14+
vi.mock("call-ai", () => {
15+
const mockImageBase64 =
16+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P///wAHAwJ/i+wW3wAAAABJRU5ErkJggg==";
17+
18+
return {
19+
callAI: vi.fn().mockResolvedValue("Mock summary"),
20+
imageGen: vi.fn().mockResolvedValue({
21+
data: [{ b64_json: mockImageBase64 }],
22+
}),
23+
};
24+
});
25+
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+
540
describe("AppCreate endpoint", () => {
641
let originalFetch: typeof global.fetch;
742
let mockFetch: typeof global.fetch;
843
let mockKV: {
944
get: (key: string, type?: string) => Promise<string | ArrayBuffer | null>;
10-
put: (key: string, value: string) => Promise<void>;
45+
put: (key: string, value: unknown) => Promise<void>;
1146
};
1247
let mockContext: {
1348
env: { KV: typeof mockKV };
@@ -57,6 +92,7 @@ describe("AppCreate endpoint", () => {
5792
env: {
5893
KV: mockKV,
5994
PUBLISH_QUEUE: mockQueue,
95+
CALLAI_API_KEY: "test-key",
6096
SERVER_OPENROUTER_API_KEY: "test-prov-key",
6197
},
6298
get: vi.fn().mockReturnValue({
@@ -111,6 +147,9 @@ describe("AppCreate endpoint", () => {
111147
expect(result.success).toBe(true);
112148
expect(result.app).toBeDefined();
113149
expect(result.app.title).toBe("Test App");
150+
expect(result.app.summary).toBe("Mock summary");
151+
expect(result.app.hasIcon).toBe(true);
152+
expect(result.app.iconKey).toBeTruthy();
114153

115154
// Verify Discord webhook was NOT called directly
116155
expect(mockFetch).not.toHaveBeenCalled();

hosting/tests/unit/queue.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,46 @@
1-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
beforeEach,
6+
afterEach,
7+
beforeAll,
8+
afterAll,
9+
vi,
10+
} from "vitest";
211
import { AppCreate, PublishEvent } from "@vibes.diy/hosting";
312
import type { OpenAPIRoute } from "chanfana";
413

14+
vi.mock("call-ai", () => {
15+
const mockImageBase64 =
16+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P///wAHAwJ/i+wW3wAAAABJRU5ErkJggg==";
17+
18+
return {
19+
callAI: vi.fn().mockResolvedValue("Mock summary"),
20+
imageGen: vi.fn().mockResolvedValue({
21+
data: [{ b64_json: mockImageBase64 }],
22+
}),
23+
};
24+
});
25+
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+
540
// Mock types
641
interface MockKV {
742
get: (key: string, type?: string) => Promise<string | ArrayBuffer | null>;
8-
put: (key: string, value: string) => Promise<void>;
43+
put: (key: string, value: unknown) => Promise<void>;
944
}
1045

1146
interface MockQueue {
@@ -16,6 +51,7 @@ interface MockContext {
1651
env: {
1752
KV: MockKV;
1853
PUBLISH_QUEUE: MockQueue;
54+
CALLAI_API_KEY?: string;
1955
};
2056
get: (key: string) => { email: string; userId: string };
2157
req: {
@@ -49,6 +85,7 @@ describe("Queue functionality", () => {
4985
env: {
5086
KV: mockKV,
5187
PUBLISH_QUEUE: mockQueue,
88+
CALLAI_API_KEY: "test-key",
5289
},
5390
get: vi.fn().mockReturnValue({
5491
email: "test@example.com",
@@ -117,6 +154,8 @@ describe("Queue functionality", () => {
117154
expect(event.app.code).toBe("console.log('hello');");
118155
expect(event.app.title).toBe("Test App");
119156
expect(event.app.userId).toBe("user-123");
157+
expect(event.app.summary).toBe("Mock summary");
158+
expect(event.app.hasIcon).toBe(true);
120159
expect(event.metadata.isUpdate).toBe(false);
121160
expect(event.metadata.timestamp).toBeTypeOf("number");
122161
}

0 commit comments

Comments
 (0)