Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/design-actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { streamDesignChat } from "./streamDesignChat";
export { createUploadBase64Image } from "./uploadBase64Image";
59 changes: 59 additions & 0 deletions apps/backend/src/design-actions/streamDesignChat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { streamText } from "ai";
import { getTracedClient } from "../utils/ai";
import { getPostHogClient } from "../utils/analytics";
import type { ModelMessage, TextPart, ImagePart, FilePart } from "ai";

export async function streamDesignChat(
messages: ModelMessage[],
model: string,
userId: string,
env: Env,
) {
const requestId = `design-chat-${userId}-${Date.now()}`;
const tracedModel = getTracedClient(model, userId, requestId, model, env);

const result = streamText({
model: tracedModel,
system:
"You are a helpful design assistant. Help users with system design, architecture diagrams, and technical design decisions.",
messages: messages.map((m, index) => {
if (m.role === "system" || m.role === "tool" || !Array.isArray(m.content))
return m;

// Don't send images from previous messages
const isLastMessage = index === messages.length - 1;
if (m.role === "user") {
return {
...m,
content: m.content.filter(
(p): p is TextPart | ImagePart | FilePart =>
p.type === "text" ||
p.type === "image" ||
(p.type === "file" && isLastMessage),
),
};
}
return {
...m,
content: m.content.filter(
(p): p is TextPart | FilePart =>
p.type === "text" || (p.type === "file" && isLastMessage),
),
};
}),
});

const phClient = getPostHogClient(env);
phClient.capture({
distinctId: userId,
event: "stream_design_chat",
properties: {
userId,
model,
requestId,
messageCount: messages.length,
},
});

return result;
}
74 changes: 74 additions & 0 deletions apps/backend/src/design-actions/uploadBase64Image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { FilePart } from "ai";

/**
* Creates a function to upload base64 images from FilePart to R2
* @param r2Bucket The R2 bucket to upload to
* @returns A function that uploads a FilePart to R2
*/
export function createUploadBase64Image(
r2Bucket: R2Bucket,
): (key: string, filePart: FilePart) => Promise<void> {
return async (key: string, filePart: FilePart): Promise<void> => {
if (!("data" in filePart) || !filePart.data) {
console.warn(`FilePart has no data property`);
return;
}

const data = filePart.data;
let fileData: Blob | ArrayBuffer | string;

// Handle different data types
if (data instanceof Blob) {
fileData = data;
} else if (data instanceof ArrayBuffer) {
fileData = data;
} else if (typeof data === "string") {
// Check if it's a base64 data URL (data:image/png;base64,...)
if (data.startsWith("data:")) {
// Extract base64 part after the comma
const base64Data = data.split(",")[1];
if (base64Data) {
// Convert base64 string to ArrayBuffer
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
fileData = bytes.buffer;
} else {
fileData = data;
}
} else {
fileData = data;
}
} else if (data instanceof ReadableStream) {
// Convert ReadableStream to ArrayBuffer
const reader = data.getReader();
const chunks: Uint8Array[] = [];
let done = false;

while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) {
chunks.push(result.value);
}
}

// Combine chunks into single ArrayBuffer
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
fileData = combined.buffer;
} else {
// Fallback: try to convert to string
fileData = String(data);
}

await r2Bucket.put(key, fileData);
};
}
66 changes: 66 additions & 0 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status";
import { apiKeyAuth } from "./middleware/auth";
import { databaseMiddleware } from "./middleware/database";
import { problems } from "./routes/problems";
import { design } from "./routes/design";
import { ProblemGenerationWorkflow } from "./workflows/problem-generation";
import type { Database } from "@repo/db";

Expand All @@ -29,6 +30,69 @@ app.get("/health", (c) =>
c.json({ status: "ok", timestamp: new Date().toISOString() }),
);

// Public R2 file serving (no auth required)
app.get("/r2/*", async (c) => {
const path = c.req.path.replace("/r2/", "");

if (!path) {
return c.json({ error: "File path required" }, 400);
}

try {
const object = await c.env.clankerrank.get(path);

if (!object) {
return c.json({ error: "File not found" }, 404);
}

// Set appropriate headers
const headers = new Headers();

// Set content type if available
if (object.httpMetadata?.contentType) {
headers.set("Content-Type", object.httpMetadata.contentType);
} else {
// Try to infer from file extension
const ext = path.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
pdf: "application/pdf",
json: "application/json",
};
headers.set(
"Content-Type",
mimeTypes[ext || ""] || "application/octet-stream",
);
}

// Set cache headers
headers.set("Cache-Control", "public, max-age=31536000, immutable");

// Set CORS headers
headers.set("Access-Control-Allow-Origin", "*");

// Return the object body with headers
return new Response(object.body, {
headers,
status: 200,
});
} catch (error) {
console.error("Error serving R2 file:", error);
return c.json(
{
error: "Failed to serve file",
message: error instanceof Error ? error.message : "Unknown error",
},
500,
);
}
});

// Register security scheme for OpenAPI docs
app.openAPIRegistry.registerComponent("securitySchemes", "ApiKeyAuth", {
type: "apiKey",
Expand All @@ -49,6 +113,7 @@ const api = new OpenAPIHono<{
api.use("*", databaseMiddleware);
api.use("*", apiKeyAuth);
api.route("/problems", problems);
api.route("/design", design);

app.route("/api/v1", api);

Expand All @@ -67,6 +132,7 @@ app.doc("/api/v1/openapi.json", {
{ name: "Problems", description: "Problem generation and retrieval" },
{ name: "Test Cases", description: "Test case generation and management" },
{ name: "Solutions", description: "Solution generation and execution" },
{ name: "Design", description: "Design text streaming" },
],
});

Expand Down
146 changes: 146 additions & 0 deletions apps/backend/src/routes/design.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { createRoute } from "@hono/zod-openapi";
import {
ApiErrorSchema,
ChatRequestSchema,
CreateSessionResponseSchema,
DesignSessionSchema,
DesignMessageSchema,
} from "@repo/api-types";
import { z } from "@hono/zod-openapi";

// Create session route
export const createSessionRoute = createRoute({
method: "post",
path: "/sessions",
tags: ["Design"],
summary: "Create a new design session",
description: "Creates a new design session and returns the session ID",
responses: {
200: {
content: {
"application/json": {
schema: z.object({
success: z.literal(true),
data: CreateSessionResponseSchema,
}),
},
},
description: "Session created successfully",
},
401: {
content: { "application/json": { schema: ApiErrorSchema } },
description: "Unauthorized",
},
},
security: [{ ApiKeyAuth: [] }],
});

// List sessions route
export const listSessionsRoute = createRoute({
method: "get",
path: "/sessions",
tags: ["Design"],
summary: "List user's design sessions",
description: "Returns all design sessions for the authenticated user",
responses: {
200: {
content: {
"application/json": {
schema: z.object({
success: z.literal(true),
data: z.array(DesignSessionSchema),
}),
},
},
description: "Sessions retrieved successfully",
},
401: {
content: { "application/json": { schema: ApiErrorSchema } },
description: "Unauthorized",
},
},
security: [{ ApiKeyAuth: [] }],
});

// Get session messages route
export const getSessionMessagesRoute = createRoute({
method: "get",
path: "/sessions/{sessionId}/messages",
tags: ["Design"],
summary: "Get session messages",
description: "Retrieves all messages for a design session",
request: {
params: z.object({
sessionId: z.string().uuid(),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: z.object({
success: z.literal(true),
data: z.array(DesignMessageSchema),
}),
},
},
description: "Messages retrieved successfully",
},
403: {
content: { "application/json": { schema: ApiErrorSchema } },
description: "Access denied",
},
404: {
content: { "application/json": { schema: ApiErrorSchema } },
description: "Session not found",
},
},
security: [{ ApiKeyAuth: [] }],
});

// Session-specific chat route
export const sessionChatRoute = createRoute({
method: "post",
path: "/sessions/{sessionId}/chat",
tags: ["Design"],
summary: "Chat with AI for design session",
description: "Streams AI responses and persists messages to session",
request: {
params: z.object({
sessionId: z.string().uuid(),
}),
body: {
content: {
"application/json": {
schema: ChatRequestSchema,
},
},
required: true,
},
},
responses: {
200: {
content: {
"text/plain": {
schema: z.string().openapi({
description: "Streaming chat response",
}),
},
},
description: "Chat response streamed successfully",
},
403: {
content: { "application/json": { schema: ApiErrorSchema } },
description: "Access denied",
},
404: {
content: { "application/json": { schema: ApiErrorSchema } },
description: "Session not found",
},
400: {
content: { "application/json": { schema: ApiErrorSchema } },
description: "Invalid model",
},
},
security: [{ ApiKeyAuth: [] }],
});
Loading