diff --git a/apps/backend/src/design-actions/index.ts b/apps/backend/src/design-actions/index.ts new file mode 100644 index 0000000..2014d1a --- /dev/null +++ b/apps/backend/src/design-actions/index.ts @@ -0,0 +1,2 @@ +export { streamDesignChat } from "./streamDesignChat"; +export { createUploadBase64Image } from "./uploadBase64Image"; diff --git a/apps/backend/src/design-actions/streamDesignChat.ts b/apps/backend/src/design-actions/streamDesignChat.ts new file mode 100644 index 0000000..6db304f --- /dev/null +++ b/apps/backend/src/design-actions/streamDesignChat.ts @@ -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; +} diff --git a/apps/backend/src/design-actions/uploadBase64Image.ts b/apps/backend/src/design-actions/uploadBase64Image.ts new file mode 100644 index 0000000..88c8b0c --- /dev/null +++ b/apps/backend/src/design-actions/uploadBase64Image.ts @@ -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 { + return async (key: string, filePart: FilePart): Promise => { + 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); + }; +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 281fa11..c83d87e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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"; @@ -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 = { + 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", @@ -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); @@ -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" }, ], }); diff --git a/apps/backend/src/routes/design.routes.ts b/apps/backend/src/routes/design.routes.ts new file mode 100644 index 0000000..2a3eac7 --- /dev/null +++ b/apps/backend/src/routes/design.routes.ts @@ -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: [] }], +}); diff --git a/apps/backend/src/routes/design.ts b/apps/backend/src/routes/design.ts new file mode 100644 index 0000000..2876283 --- /dev/null +++ b/apps/backend/src/routes/design.ts @@ -0,0 +1,230 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { streamDesignChat, createUploadBase64Image } from "@/design-actions"; +import { + createSessionRoute, + listSessionsRoute, + getSessionMessagesRoute, + sessionChatRoute, +} from "./design.routes"; +import type { Database } from "@repo/db"; +import { + getModelByName, + createDesignSession, + listDesignSessionsByUser, + getDesignSession, + loadDesignMessages, + saveDesignMessages, + updateDesignSessionTitle, +} from "@repo/db"; +import { + convertToModelMessages, + createIdGenerator, + type ModelMessage, +} from "ai"; + +const design = new OpenAPIHono<{ + Bindings: Env; + Variables: { + userId: string; + isAdmin: boolean; + db: Database; + }; +}>(); + +// Error handler for design routes to log validation errors +design.onError((err, c) => { + console.error("Design route error:", err); + console.error("Error details:", { + message: err.message, + status: "status" in err ? err.status : undefined, + stack: err.stack, + path: c.req.path, + method: c.req.method, + }); + + // Re-throw to let global error handler handle it + throw err; +}); + +// Create session +design.openapi(createSessionRoute, async (c) => { + const userId = c.get("userId"); + const db = c.get("db"); + + const sessionId = await createDesignSession(userId, undefined, db); + + return c.json({ success: true as const, data: { sessionId } }, 200); +}); + +// List sessions +design.openapi(listSessionsRoute, async (c) => { + const userId = c.get("userId"); + const db = c.get("db"); + + const sessions = await listDesignSessionsByUser(userId, db); + + return c.json({ success: true as const, data: sessions }, 200); +}); + +// Get session messages +design.openapi(getSessionMessagesRoute, async (c) => { + const { sessionId } = c.req.valid("param"); + const userId = c.get("userId"); + const db = c.get("db"); + + // Verify session belongs to user + const session = await getDesignSession(sessionId, db); + if (!session) { + throw new HTTPException(404, { message: "Session not found" }); + } + if (session.userId !== userId) { + throw new HTTPException(403, { message: "Access denied" }); + } + + const messages = await loadDesignMessages(sessionId, db, c.env.R2_BASE_URL); + console.log("Loaded messages:", JSON.stringify(messages, null, 2)); + + // Filter out tool messages and transform parts to match schema + const transformedMessages = messages + .filter((msg) => msg.role !== "tool") + .map((msg) => ({ + id: msg.id, + role: msg.role as "user" | "assistant" | "system", + parts: msg.parts + .filter( + ( + part, + ): part is + | { type: "text"; text: string } + | { + type: "file"; + url: string; + mediaType: string; + filename: string; + } => part.type === "text" || part.type === "file", + ) + .map((part) => { + if (part.type === "text") { + return { type: "text" as const, text: part.text }; + } + return { + type: "file" as const, + url: part.url, + mediaType: part.mediaType, + filename: part.filename, + }; + }), + createdAt: msg.createdAt, + })); + + return c.json( + { + success: true as const, + data: transformedMessages, + }, + 200, + ); +}); + +// Helper function for title generation +function generateTitleFromMessage(message: ModelMessage): string { + const content = + typeof message.content === "string" + ? message.content + : JSON.stringify(message.content); + + // Simple heuristic: take first 50 chars + const title = content.substring(0, 50).trim(); + return title.length < content.length ? `${title}...` : title; +} + +// Session-specific chat (with persistence) +design.openapi(sessionChatRoute, async (c) => { + const { sessionId } = c.req.valid("param"); + const userId = c.get("userId"); + const db = c.get("db"); + const body = c.req.valid("json"); + + // Verify session ownership + const session = await getDesignSession(sessionId, db); + if (!session) { + throw new HTTPException(404, { message: "Session not found" }); + } + if (session.userId !== userId) { + throw new HTTPException(403, { message: "Access denied" }); + } + + const modelName = body.model || "google/gemini-2.0-flash"; + const model = await getModelByName(modelName, db); + if (!model) { + throw new HTTPException(400, { message: `Invalid model: "${modelName}"` }); + } + + // Use messages from request (client sends full conversation) + // The validated body.messages conforms to ChatMessageSchema which uses .passthrough() + // Cast is needed because Zod's permissive schema doesn't match AI SDK's strict UIMessage type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const allMessages = body.messages as any; + const normalizedMessages = convertToModelMessages(allMessages); + + // Create R2 upload function for base64 images + const uploadBase64Image = createUploadBase64Image(c.env.clankerrank); + + await saveDesignMessages( + sessionId, + normalizedMessages.map((m, index) => ({ + ...m, + id: allMessages[index]?.id ?? crypto.randomUUID(), + })), + uploadBase64Image, + db, + ); + + const result = await streamDesignChat( + normalizedMessages, + modelName, + userId, + c.env, + ); + + // Add onFinish callback to save both user and assistant messages + const response = result.toUIMessageStreamResponse({ + generateMessageId: createIdGenerator({ + prefix: "design-message-", + size: 16, + }), + async onFinish({ messages: updatedMessages }) { + const modelMessages = convertToModelMessages(updatedMessages); + + // Save all messages (append-only will save any new user + assistant messages) + await saveDesignMessages( + sessionId, + modelMessages.map((m, index) => ({ + ...m, + id: updatedMessages[index].id, + })), + uploadBase64Image, + db, + ); + + // Auto-generate title from first assistant message if needed + if (!session.title && modelMessages.length >= 2) { + const firstAssistantMsg = modelMessages.find( + (m) => m.role === "assistant", + ); + if (firstAssistantMsg) { + const title = generateTitleFromMessage(firstAssistantMsg); + await updateDesignSessionTitle(sessionId, title, db); + } + } + }, + }); + + // OpenAPIHono doesn't properly type streaming responses + // The toUIMessageStreamResponse returns a Response object which is correct for streaming + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return response as any; +}); + +export { design }; diff --git a/apps/backend/worker-configuration.d.ts b/apps/backend/worker-configuration.d.ts index a9029f4..a0a0ad2 100644 --- a/apps/backend/worker-configuration.d.ts +++ b/apps/backend/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: bcfe2717b797e7bc4de1e9e7b353bc6e) +// Generated by Wrangler by running `wrangler types` (hash: 310ec3dade6c0d245c18e9f89cc7ef4f) // Runtime types generated with workerd@1.20251125.0 2025-11-27 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -19,7 +19,9 @@ declare namespace Cloudflare { WORKOS_COOKIE_PASSWORD: string; NEXT_PUBLIC_WORKOS_REDIRECT_URI: string; POSTHOG_API_KEY: string; + R2_BASE_URL: string; Sandbox: DurableObjectNamespace; + clankerrank: R2Bucket; PROBLEM_GENERATION_WORKFLOW: Workflow< Parameters< import("./src/index").ProblemGenerationWorkflow["run"] @@ -49,6 +51,7 @@ declare namespace NodeJS { | "WORKOS_COOKIE_PASSWORD" | "NEXT_PUBLIC_WORKOS_REDIRECT_URI" | "POSTHOG_API_KEY" + | "R2_BASE_URL" > > {} } diff --git a/apps/backend/wrangler.jsonc b/apps/backend/wrangler.jsonc index e555c7e..074faca 100644 --- a/apps/backend/wrangler.jsonc +++ b/apps/backend/wrangler.jsonc @@ -40,4 +40,11 @@ "persist": true, }, }, + "r2_buckets": [ + { + "bucket_name": "clankerrank", + "binding": "clankerrank", + "remote": true, + }, + ], } diff --git a/apps/web/actions/create-design-session.ts b/apps/web/actions/create-design-session.ts new file mode 100644 index 0000000..88e7a45 --- /dev/null +++ b/apps/web/actions/create-design-session.ts @@ -0,0 +1,28 @@ +"use server"; + +export async function createDesignSession( + encryptedUserId: string, +): Promise<{ sessionId: string }> { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/design/sessions`, + { + method: "POST", + headers: { + "X-API-Key": encryptedUserId, + "Content-Type": "application/json", + }, + }, + ); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create session: ${errorText}`); + } + + const json = await res.json(); + if (!json.success) { + throw new Error(json.error?.message || "Failed to create session"); + } + + return json.data; +} diff --git a/apps/web/actions/get-design-session-messages.ts b/apps/web/actions/get-design-session-messages.ts new file mode 100644 index 0000000..1862863 --- /dev/null +++ b/apps/web/actions/get-design-session-messages.ts @@ -0,0 +1,15 @@ +"use server"; + +import { apiGet } from "@/lib/api-client"; +import type { DesignMessage } from "@repo/api-types"; + +export async function getDesignSessionMessages( + sessionId: string, + encryptedUserId?: string, +): Promise { + return apiGet( + `/sessions/${sessionId}/messages`, + encryptedUserId, + "/api/v1/design", + ); +} diff --git a/apps/web/actions/list-design-sessions.ts b/apps/web/actions/list-design-sessions.ts new file mode 100644 index 0000000..71c1aaa --- /dev/null +++ b/apps/web/actions/list-design-sessions.ts @@ -0,0 +1,17 @@ +"use server"; + +import { apiGet } from "@/lib/api-client"; +import type { DesignSession } from "@repo/api-types"; + +export async function listDesignSessions( + encryptedUserId?: string, +): Promise { + if (!encryptedUserId) { + throw new Error("Encrypted user ID is required"); + } + return apiGet( + "/sessions", + encryptedUserId, + "/api/v1/design", + ); +} diff --git a/apps/web/app/design/[designId]/page.tsx b/apps/web/app/design/[designId]/page.tsx new file mode 100644 index 0000000..861dc84 --- /dev/null +++ b/apps/web/app/design/[designId]/page.tsx @@ -0,0 +1,42 @@ +"use server"; +import { withAuth } from "@workos-inc/authkit-nextjs"; +import { redirect } from "next/navigation"; +import { encryptUserId } from "@/lib/auth-utils"; +import { ClientFacingUserObject } from "@/lib/auth-types"; +import ExcalidrawWrapper from "../components/excalidraw-wrapper"; + +export default async function Page() { + const { user } = await withAuth({ + ensureSignedIn: true, + }); + + if ( + !user || + !user.email || + !user.firstName || + !user.lastName || + !user.profilePictureUrl || + !user.createdAt || + !user.updatedAt + ) { + return redirect("/login"); + } + + const encryptedUserId = encryptUserId(user.id); + const clientFacingUser: ClientFacingUserObject = { + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + profilePictureUrl: user.profilePictureUrl, + apiKey: encryptedUserId, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + + return ( + + ); +} diff --git a/apps/web/app/design/atoms/chat-atoms.ts b/apps/web/app/design/atoms/chat-atoms.ts new file mode 100644 index 0000000..3164293 --- /dev/null +++ b/apps/web/app/design/atoms/chat-atoms.ts @@ -0,0 +1,48 @@ +"use client"; + +import { atom } from "jotai"; +import { atomFamily } from "jotai/utils"; +import { Chat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { DesignMessage } from "../../../../../packages/api-types/src/schemas/design"; + +interface DesignChatState { + chat: Chat; + encryptedUserId: string; +} + +/** + * Atom family for Chat instances, keyed by designSessionId + * Each session gets its own Chat instance with encryptedUserId stored + */ +export const designChatAtomFamily = atomFamily( + (params: { designSessionId: string; encryptedUserId: string }) => { + const { designSessionId, encryptedUserId } = params; + + // Create a new Chat instance with session-specific transport + const chat = new Chat({ + id: designSessionId, + transport: new DefaultChatTransport({ + api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/design/sessions/${designSessionId}/chat`, + headers: { + "X-API-Key": encryptedUserId, + }, + }), + }); + + return atom({ chat, encryptedUserId }); + }, + (a, b) => + a.designSessionId === b.designSessionId && + a.encryptedUserId === b.encryptedUserId, +); + +/** + * Hook to get or create a Chat atom for a design session + */ +export function useDesignChatAtom( + designSessionId: string, + encryptedUserId: string, +) { + return designChatAtomFamily({ designSessionId, encryptedUserId }); +} diff --git a/apps/web/app/design/components/chat-panel.tsx b/apps/web/app/design/components/chat-panel.tsx new file mode 100644 index 0000000..543f9cf --- /dev/null +++ b/apps/web/app/design/components/chat-panel.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useAtomValue } from "jotai"; +import { useParams } from "next/navigation"; +import { useChat } from "@ai-sdk/react"; +import { useDesignSessionMessages } from "@/hooks/use-design"; +import { + MessageAction, + MessageActions, + Message, + MessageContent, + MessageResponse, + MessageAttachment, +} from "@/components/ai-elements/message"; +import { useDesignChatAtom } from "../atoms/chat-atoms"; +import { CopyIcon, RefreshCcwIcon } from "lucide-react"; +import { exportToCanvas, Excalidraw } from "@excalidraw/excalidraw"; +import { DesignMessage } from "../../../../../packages/api-types/src/schemas/design"; +import { cn } from "@/lib/utils"; + +interface ChatPanelProps { + encryptedUserId: string; + excalidrawAPI: + | Parameters< + NonNullable["excalidrawAPI"]> + >[0] + | null; +} + +export function ChatPanel({ encryptedUserId, excalidrawAPI }: ChatPanelProps) { + const [prompt, setPrompt] = useState(""); + const params = useParams(); + const designSessionId = (params.designId as string) ?? ""; + + // Get the atom directly using the hook (must be called unconditionally) + const chatAtom = useDesignChatAtom(designSessionId, encryptedUserId); + const chatState = useAtomValue(chatAtom); + // atomFamily always creates a DesignChatState, so chatState should always exist + const { chat, encryptedUserId: userId } = chatState; + + // useChat must be called unconditionally to maintain hook order + const { messages, status, setMessages, sendMessage, regenerate } = + useChat({ + chat, + }); + + // Load initial messages using React Query hook (must be called unconditionally) + const { + isLoading: isLoadingMessages, + data: designMessages, + error: messagesError, + } = useDesignSessionMessages(designSessionId, userId || encryptedUserId); + + // Sync initial messages with chat when they're loaded + // Must be called before early return to maintain hook order + useEffect(() => { + if (designMessages && designMessages.length > 0 && messages.length === 0) { + console.log("designMessages", designMessages); + setMessages(designMessages); + } + }, [designMessages, messages.length, setMessages]); + + // Early return after all hooks are called + if (!designSessionId || !chat) { + return null; + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!prompt.trim() || isLoadingMessages) return; + + // Export Excalidraw scene as base64 image + if (excalidrawAPI) { + try { + const elements = excalidrawAPI.getSceneElements(); + const appState = excalidrawAPI.getAppState(); + const files = excalidrawAPI.getFiles(); + + const canvas = await exportToCanvas({ + elements, + appState, + files, + getDimensions: () => ({ width: 800, height: 600, scale: 1 }), + }); + + const base64Image = canvas.toDataURL("image/png"); + console.log("Excalidraw image (base64):", base64Image); + await sendMessage({ + text: prompt, + files: [ + { + type: "file", + filename: "excalidraw.png", + mediaType: "image/png", + url: base64Image, + }, + ], + }); + } catch (error) { + console.error("Failed to export Excalidraw image:", error); + } + } + setPrompt(""); + }; + + const isDisabled = + isLoadingMessages || status === "streaming" || status === "submitted"; + + return ( +
+ {/* Messages area */} +
+ {isLoadingMessages ? ( +
Loading messages...
+ ) : messagesError ? ( +
+ Failed to load messages:{" "} + {messagesError instanceof Error + ? messagesError.message + : "Unknown error"} +
+ ) : ( + <> + {messages.map((message, i) => ( +
+ {message.parts.map((part, partIndex) => { + switch (part.type) { + case "file": + return ( +
+ +
+ ); + case "text": + return ( + + + {part.text} + + {message.role === "assistant" && + i === messages.length - 1 && ( + + regenerate()} + label="Retry" + > + + + + navigator.clipboard.writeText(part.text) + } + label="Copy" + > + + + + )} + + ); + default: + return null; + } + })} +
+ ))} + {(status === "streaming" || status === "submitted") && ( +
+
+

Thinking...

+
+
+ )} + + )} +
+ + {/* Input area */} +
+
+ setPrompt(e.target.value)} + placeholder="Ask about design..." + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isDisabled} + /> + +
+
+
+ ); +} diff --git a/apps/web/app/design/components/excalidraw-wrapper.tsx b/apps/web/app/design/components/excalidraw-wrapper.tsx new file mode 100644 index 0000000..6208086 --- /dev/null +++ b/apps/web/app/design/components/excalidraw-wrapper.tsx @@ -0,0 +1,173 @@ +"use client"; +import { useState } from "react"; +import { + Excalidraw, + convertToExcalidrawElements, +} from "@excalidraw/excalidraw"; +// import { generateShapes } from "@/lib/ai"; + +import "@excalidraw/excalidraw/index.css"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ClientFacingUserObject } from "@/lib/auth-types"; +import { AppHeader } from "@/components/app-header"; +import { ChatPanel } from "./chat-panel"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; + +interface ExcalidrawWrapperProps { + encryptedUserId: string; + user: ClientFacingUserObject; +} + +export default function ExcalidrawWrapper({ + encryptedUserId, + user, +}: ExcalidrawWrapperProps) { + const [excalidrawAPI, setExcalidrawAPI] = useState< + | Parameters< + NonNullable["excalidrawAPI"]> + >[0] + | null + >(null); + const [prompt, setPrompt] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + + const initialElements = convertToExcalidrawElements([ + { + type: "rectangle", + x: 100, + y: 250, + }, + { + type: "ellipse", + x: 250, + y: 250, + }, + { + type: "diamond", + x: 380, + y: 250, + }, + ]); + + const addRandomElement = () => { + if (!excalidrawAPI) return; + + const elementTypes = ["rectangle", "ellipse", "diamond"] as const; + + const randomIndex = Math.floor(Math.random() * elementTypes.length); + const randomType = elementTypes[randomIndex]; + + if (!randomType) return; // Type guard to ensure randomType is defined + + // Generate random position (assuming canvas viewport is roughly 800x600) + const randomX = Math.random() * 600 + 50; + const randomY = Math.random() * 400 + 50; + + const newElement = convertToExcalidrawElements([ + { + type: randomType, + x: randomX, + y: randomY, + }, + ]); + + // Get current elements and add the new one + const currentElements = excalidrawAPI.getSceneElements(); + excalidrawAPI.updateScene({ + elements: [...currentElements, ...newElement], + }); + }; + + const logElementsAsJSON = () => { + if (!excalidrawAPI) return; + + const elements = excalidrawAPI.getSceneElements(); + console.log(JSON.stringify(elements, null, 2)); + }; + + const handleGenerate = async () => { + if (!excalidrawAPI || !prompt.trim()) return; + + setIsGenerating(true); + try { + // const currentElements = excalidrawAPI.getSceneElements(); + // const newElements = await generateShapes(prompt, [...currentElements]); + // Cast to expected type - schema ensures valid structure + // const convertedElements = convertToExcalidrawElements( + // newElements as Parameters[0] + // ); + // excalidrawAPI.updateScene({ + // elements: [...currentElements, ...convertedElements], + // }); + setPrompt(""); + } catch (error) { + console.error("Generation failed:", error); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+ + + {/* Chat panel - left side */} + + + + + + + {/* Excalidraw canvas - right side */} + +
+
+ {excalidrawAPI && ( + <> + + +
+ setPrompt(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleGenerate()} + placeholder="Describe a diagram..." + disabled={isGenerating} + /> + +
+ + )} +
+
+ setExcalidrawAPI(api)} + initialData={{ + elements: initialElements, + scrollToContent: true, + }} + // viewModeEnabled={true} + /> +
+
+
+
+
+ ); +} diff --git a/apps/web/app/design/page.tsx b/apps/web/app/design/page.tsx new file mode 100644 index 0000000..1fa4dc0 --- /dev/null +++ b/apps/web/app/design/page.tsx @@ -0,0 +1,21 @@ +import { withAuth } from "@workos-inc/authkit-nextjs"; +import { redirect } from "next/navigation"; +import { encryptUserId } from "@/lib/auth-utils"; +import { createDesignSession } from "@/actions/create-design-session"; + +export default async function Page() { + const { user } = await withAuth({ + ensureSignedIn: true, + }); + + if (!user) { + return redirect("/login"); + } + + const encryptedUserId = encryptUserId(user.id); + + // Create new session and redirect + const { sessionId } = await createDesignSession(encryptedUserId); + + redirect(`/design/${sessionId}`); +} diff --git a/apps/web/app/problem/[problemId]/components/problem-render.tsx b/apps/web/app/problem/[problemId]/components/problem-render.tsx index 815bdab..f8fec21 100644 --- a/apps/web/app/problem/[problemId]/components/problem-render.tsx +++ b/apps/web/app/problem/[problemId]/components/problem-render.tsx @@ -22,10 +22,9 @@ import { type CodeGenLanguage, } from "@/hooks/use-problem"; import { Skeleton } from "@/components/ui/skeleton"; -import Link from "next/link"; import { ClientFacingUserObject } from "@/lib/auth-types"; -import { signOutAction } from "@/app/(auth)/signout"; import { Loader2Icon, PlayIcon, SendIcon } from "lucide-react"; +import { AppHeader } from "@/components/app-header"; import { Select, SelectContent, @@ -45,7 +44,6 @@ import { } from "@/components/ui/alert-dialog"; import NonAdminProblemView from "./non-admin-problem-view"; import CustomTestInputs from "./custom-test-inputs"; -import { IssueReportDialog } from "./issue-report-dialog"; export default function ProblemRender({ problemId, @@ -192,36 +190,7 @@ export default function ProblemRender({ return (
-
-
- -

- ClankerLoop -

- -

·

-
- hi {user.firstName.toLowerCase()}{" "} -
{ - await signOutAction(); - }} - className="inline" - > - -
-
- -
-
+ ; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = "sm", + variant = "ghost", + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = "sm", + variant = "ghost", + ...props +}: ArtifactActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/apps/web/components/ai-elements/canvas.tsx b/apps/web/components/ai-elements/canvas.tsx new file mode 100644 index 0000000..5aa83cb --- /dev/null +++ b/apps/web/components/ai-elements/canvas.tsx @@ -0,0 +1,22 @@ +import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; +import type { ReactNode } from "react"; +import "@xyflow/react/dist/style.css"; + +type CanvasProps = ReactFlowProps & { + children?: ReactNode; +}; + +export const Canvas = ({ children, ...props }: CanvasProps) => ( + + + {children} + +); diff --git a/apps/web/components/ai-elements/chain-of-thought.tsx b/apps/web/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 0000000..c5ffeaa --- /dev/null +++ b/apps/web/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { + BrainIcon, + ChevronDownIcon, + DotIcon, + type LucideIcon, +} from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, memo, useContext, useMemo } from "react"; + +type ChainOfThoughtContextValue = { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +const ChainOfThoughtContext = createContext( + null, +); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error( + "ChainOfThought components must be used within ChainOfThought", + ); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<"div"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export const ChainOfThought = memo( + ({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props + }: ChainOfThoughtProps) => { + const [isOpen, setIsOpen] = useControllableState({ + prop: open, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const chainOfThoughtContext = useMemo( + () => ({ isOpen, setIsOpen }), + [isOpen, setIsOpen], + ); + + return ( + +
+ {children} +
+
+ ); + }, +); + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export const ChainOfThoughtHeader = memo( + ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { + const { isOpen, setIsOpen } = useChainOfThought(); + + return ( + + + + + {children ?? "Chain of Thought"} + + + + + ); + }, +); + +export type ChainOfThoughtStepProps = ComponentProps<"div"> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: "complete" | "active" | "pending"; +}; + +export const ChainOfThoughtStep = memo( + ({ + className, + icon: Icon = DotIcon, + label, + description, + status = "complete", + children, + ...props + }: ChainOfThoughtStepProps) => { + const statusStyles = { + complete: "text-muted-foreground", + active: "text-foreground", + pending: "text-muted-foreground/50", + }; + + return ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ); + }, +); + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export const ChainOfThoughtSearchResults = memo( + ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( +
+ ), +); + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export const ChainOfThoughtSearchResult = memo( + ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( + + {children} + + ), +); + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export const ChainOfThoughtContent = memo( + ({ className, children, ...props }: ChainOfThoughtContentProps) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {children} + + + ); + }, +); + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export const ChainOfThoughtImage = memo( + ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ), +); + +ChainOfThought.displayName = "ChainOfThought"; +ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; +ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; +ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; +ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; +ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; +ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; diff --git a/apps/web/components/ai-elements/checkpoint.tsx b/apps/web/components/ai-elements/checkpoint.tsx new file mode 100644 index 0000000..4fe5c91 --- /dev/null +++ b/apps/web/components/ai-elements/checkpoint.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { BookmarkIcon, type LucideProps } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type CheckpointProps = HTMLAttributes; + +export const Checkpoint = ({ + className, + children, + ...props +}: CheckpointProps) => ( +
+ {children} + +
+); + +export type CheckpointIconProps = LucideProps; + +export const CheckpointIcon = ({ + className, + children, + ...props +}: CheckpointIconProps) => + children ?? ( + + ); + +export type CheckpointTriggerProps = ComponentProps & { + tooltip?: string; +}; + +export const CheckpointTrigger = ({ + children, + variant = "ghost", + size = "sm", + tooltip, + ...props +}: CheckpointTriggerProps) => + tooltip ? ( + + + + + + {tooltip} + + + ) : ( + + ); diff --git a/apps/web/components/ai-elements/code-block.tsx b/apps/web/components/ai-elements/code-block.tsx new file mode 100644 index 0000000..c12be38 --- /dev/null +++ b/apps/web/components/ai-elements/code-block.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; + +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +type CodeBlockContextType = { + code: string; +}; + +const CodeBlockContext = createContext({ + code: "", +}); + +const lineNumberTransformer: ShikiTransformer = { + name: "line-numbers", + line(node, line) { + node.children.unshift({ + type: "element", + tagName: "span", + properties: { + className: [ + "inline-block", + "min-w-10", + "mr-4", + "text-right", + "select-none", + "text-muted-foreground", + ], + }, + children: [{ type: "text", value: String(line) }], + }); + }, +}; + +export async function highlightCode( + code: string, + language: BundledLanguage, + showLineNumbers = false, +) { + const transformers: ShikiTransformer[] = showLineNumbers + ? [lineNumberTransformer] + : []; + + return await Promise.all([ + codeToHtml(code, { + lang: language, + theme: "one-light", + transformers, + }), + codeToHtml(code, { + lang: language, + theme: "one-dark-pro", + transformers, + }), + ]); +} + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const [html, setHtml] = useState(""); + const [darkHtml, setDarkHtml] = useState(""); + const mounted = useRef(false); + + useEffect(() => { + highlightCode(code, language, showLineNumbers).then(([light, dark]) => { + if (!mounted.current) { + setHtml(light); + setDarkHtml(dark); + mounted.current = true; + } + }); + + return () => { + mounted.current = false; + }; + }, [code, language, showLineNumbers]); + + return ( + +
+
+
+
+ {children && ( +
+ {children} +
+ )} +
+
+ + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + setTimeout(() => setIsCopied(false), timeout); + } catch (error) { + onError?.(error as Error); + } + }; + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; diff --git a/apps/web/components/ai-elements/confirmation.tsx b/apps/web/components/ai-elements/confirmation.tsx new file mode 100644 index 0000000..bff67b6 --- /dev/null +++ b/apps/web/components/ai-elements/confirmation.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ToolUIPart } from "ai"; +import { + type ComponentProps, + createContext, + type ReactNode, + useContext, +} from "react"; + +type ToolUIPartApproval = + | { + id: string; + approved?: never; + reason?: never; + } + | { + id: string; + approved: boolean; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: false; + reason?: string; + } + | undefined; + +type ConfirmationContextValue = { + approval: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +const ConfirmationContext = createContext( + null, +); + +const useConfirmation = () => { + const context = useContext(ConfirmationContext); + + if (!context) { + throw new Error("Confirmation components must be used within Confirmation"); + } + + return context; +}; + +export type ConfirmationProps = ComponentProps & { + approval?: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +export const Confirmation = ({ + className, + approval, + state, + ...props +}: ConfirmationProps) => { + if (!approval || state === "input-streaming" || state === "input-available") { + return null; + } + + return ( + + + + ); +}; + +export type ConfirmationTitleProps = ComponentProps; + +export const ConfirmationTitle = ({ + className, + ...props +}: ConfirmationTitleProps) => ( + +); + +export type ConfirmationRequestProps = { + children?: ReactNode; +}; + +export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + // @ts-expect-error state only available in AI SDK v6 + if (state !== "approval-requested") { + return null; + } + + return children; +}; + +export type ConfirmationAcceptedProps = { + children?: ReactNode; +}; + +export const ConfirmationAccepted = ({ + children, +}: ConfirmationAcceptedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when approved and in response states + if ( + !approval?.approved || + // @ts-expect-error state only available in AI SDK v6 + (state !== "approval-responded" && + // @ts-expect-error state only available in AI SDK v6 + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationRejectedProps = { + children?: ReactNode; +}; + +export const ConfirmationRejected = ({ + children, +}: ConfirmationRejectedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when rejected and in response states + if ( + approval?.approved !== false || + // @ts-expect-error state only available in AI SDK v6 + (state !== "approval-responded" && + // @ts-expect-error state only available in AI SDK v6 + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationActionsProps = ComponentProps<"div">; + +export const ConfirmationActions = ({ + className, + ...props +}: ConfirmationActionsProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + // @ts-expect-error state only available in AI SDK v6 + if (state !== "approval-requested") { + return null; + } + + return ( +
+ ); +}; + +export type ConfirmationActionProps = ComponentProps; + +export const ConfirmationAction = (props: ConfirmationActionProps) => ( + + )} + + ); +}; + +export type ContextContentProps = ComponentProps; + +export const ContextContent = ({ + className, + ...props +}: ContextContentProps) => ( + +); + +export type ContextContentHeaderProps = ComponentProps<"div">; + +export const ContextContentHeader = ({ + children, + className, + ...props +}: ContextContentHeaderProps) => { + const { usedTokens, maxTokens } = useContextValue(); + const usedPercent = usedTokens / maxTokens; + const displayPct = new Intl.NumberFormat("en-US", { + style: "percent", + maximumFractionDigits: 1, + }).format(usedPercent); + const used = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(usedTokens); + const total = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(maxTokens); + + return ( +
+ {children ?? ( + <> +
+

{displayPct}

+

+ {used} / {total} +

+
+
+ +
+ + )} +
+ ); +}; + +export type ContextContentBodyProps = ComponentProps<"div">; + +export const ContextContentBody = ({ + children, + className, + ...props +}: ContextContentBodyProps) => ( +
+ {children} +
+); + +export type ContextContentFooterProps = ComponentProps<"div">; + +export const ContextContentFooter = ({ + children, + className, + ...props +}: ContextContentFooterProps) => { + const { modelId, usage } = useContextValue(); + const costUSD = modelId + ? getUsage({ + modelId, + usage: { + input: usage?.inputTokens ?? 0, + output: usage?.outputTokens ?? 0, + }, + }).costUSD?.totalUSD + : undefined; + const totalCost = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(costUSD ?? 0); + + return ( +
+ {children ?? ( + <> + Total cost + {totalCost} + + )} +
+ ); +}; + +export type ContextInputUsageProps = ComponentProps<"div">; + +export const ContextInputUsage = ({ + className, + children, + ...props +}: ContextInputUsageProps) => { + const { usage, modelId } = useContextValue(); + const inputTokens = usage?.inputTokens ?? 0; + + if (children) { + return children; + } + + if (!inputTokens) { + return null; + } + + const inputCost = modelId + ? getUsage({ + modelId, + usage: { input: inputTokens, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const inputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(inputCost ?? 0); + + return ( +
+ Input + +
+ ); +}; + +export type ContextOutputUsageProps = ComponentProps<"div">; + +export const ContextOutputUsage = ({ + className, + children, + ...props +}: ContextOutputUsageProps) => { + const { usage, modelId } = useContextValue(); + const outputTokens = usage?.outputTokens ?? 0; + + if (children) { + return children; + } + + if (!outputTokens) { + return null; + } + + const outputCost = modelId + ? getUsage({ + modelId, + usage: { input: 0, output: outputTokens }, + }).costUSD?.totalUSD + : undefined; + const outputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(outputCost ?? 0); + + return ( +
+ Output + +
+ ); +}; + +export type ContextReasoningUsageProps = ComponentProps<"div">; + +export const ContextReasoningUsage = ({ + className, + children, + ...props +}: ContextReasoningUsageProps) => { + const { usage, modelId } = useContextValue(); + const reasoningTokens = usage?.reasoningTokens ?? 0; + + if (children) { + return children; + } + + if (!reasoningTokens) { + return null; + } + + const reasoningCost = modelId + ? getUsage({ + modelId, + usage: { reasoningTokens }, + }).costUSD?.totalUSD + : undefined; + const reasoningCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(reasoningCost ?? 0); + + return ( +
+ Reasoning + +
+ ); +}; + +export type ContextCacheUsageProps = ComponentProps<"div">; + +export const ContextCacheUsage = ({ + className, + children, + ...props +}: ContextCacheUsageProps) => { + const { usage, modelId } = useContextValue(); + const cacheTokens = usage?.cachedInputTokens ?? 0; + + if (children) { + return children; + } + + if (!cacheTokens) { + return null; + } + + const cacheCost = modelId + ? getUsage({ + modelId, + usage: { cacheReads: cacheTokens, input: 0, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const cacheCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cacheCost ?? 0); + + return ( +
+ Cache + +
+ ); +}; + +const TokensWithCost = ({ + tokens, + costText, +}: { + tokens?: number; + costText?: string; +}) => ( + + {tokens === undefined + ? "—" + : new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(tokens)} + {costText ? ( + • {costText} + ) : null} + +); diff --git a/apps/web/components/ai-elements/controls.tsx b/apps/web/components/ai-elements/controls.tsx new file mode 100644 index 0000000..f994a63 --- /dev/null +++ b/apps/web/components/ai-elements/controls.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Controls as ControlsPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +export type ControlsProps = ComponentProps; + +export const Controls = ({ className, ...props }: ControlsProps) => ( + button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!", + className, + )} + {...props} + /> +); diff --git a/apps/web/components/ai-elements/conversation.tsx b/apps/web/components/ai-elements/conversation.tsx new file mode 100644 index 0000000..4920106 --- /dev/null +++ b/apps/web/components/ai-elements/conversation.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/apps/web/components/ai-elements/edge.tsx b/apps/web/components/ai-elements/edge.tsx new file mode 100644 index 0000000..b737895 --- /dev/null +++ b/apps/web/components/ai-elements/edge.tsx @@ -0,0 +1,140 @@ +import { + BaseEdge, + type EdgeProps, + getBezierPath, + getSimpleBezierPath, + type InternalNode, + type Node, + Position, + useInternalNode, +} from "@xyflow/react"; + +const Temporary = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: EdgeProps) => { + const [edgePath] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +const getHandleCoordsByPosition = ( + node: InternalNode, + handlePosition: Position, +) => { + // Choose the handle type based on position - Left is for target, Right is for source + const handleType = handlePosition === Position.Left ? "target" : "source"; + + const handle = node.internals.handleBounds?.[handleType]?.find( + (h) => h.position === handlePosition, + ); + + if (!handle) { + return [0, 0] as const; + } + + let offsetX = handle.width / 2; + let offsetY = handle.height / 2; + + // this is a tiny detail to make the markerEnd of an edge visible. + // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset + // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position + switch (handlePosition) { + case Position.Left: + offsetX = 0; + break; + case Position.Right: + offsetX = handle.width; + break; + case Position.Top: + offsetY = 0; + break; + case Position.Bottom: + offsetY = handle.height; + break; + default: + throw new Error(`Invalid handle position: ${handlePosition}`); + } + + const x = node.internals.positionAbsolute.x + handle.x + offsetX; + const y = node.internals.positionAbsolute.y + handle.y + offsetY; + + return [x, y] as const; +}; + +const getEdgeParams = ( + source: InternalNode, + target: InternalNode, +) => { + const sourcePos = Position.Right; + const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); + const targetPos = Position.Left; + const [tx, ty] = getHandleCoordsByPosition(target, targetPos); + + return { + sx, + sy, + tx, + ty, + sourcePos, + targetPos, + }; +}; + +const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!(sourceNode && targetNode)) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode, + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetX: tx, + targetY: ty, + targetPosition: targetPos, + }); + + return ( + <> + + + + + + ); +}; + +export const Edge = { + Temporary, + Animated, +}; diff --git a/apps/web/components/ai-elements/image.tsx b/apps/web/components/ai-elements/image.tsx new file mode 100644 index 0000000..32be66a --- /dev/null +++ b/apps/web/components/ai-elements/image.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/utils"; +import type { Experimental_GeneratedImage } from "ai"; + +export type ImageProps = Experimental_GeneratedImage & { + className?: string; + alt?: string; +}; + +export const Image = ({ base64, mediaType, ...props }: ImageProps) => ( + // eslint-disable-next-line @next/next/no-img-element + {props.alt} +); diff --git a/apps/web/components/ai-elements/inline-citation.tsx b/apps/web/components/ai-elements/inline-citation.tsx new file mode 100644 index 0000000..6459409 --- /dev/null +++ b/apps/web/components/ai-elements/inline-citation.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +export type InlineCitationProps = ComponentProps<"span">; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<"span">; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; +}; + +export const InlineCitationCardTrigger = ({ + sources, + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {sources[0] ? ( + <> + {new URL(sources[0]).hostname}{" "} + {sources.length > 1 && `+${sources.length - 1}`} + + ) : ( + "unknown" + )} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<"div">; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +const CarouselApiContext = createContext(undefined); + +const useCarouselApi = () => { + const context = useContext(CarouselApiContext); + return context; +}; + +export type InlineCitationCarouselProps = ComponentProps; + +export const InlineCitationCarousel = ({ + className, + children, + ...props +}: InlineCitationCarouselProps) => { + const [api, setApi] = useState(); + + return ( + + + {children} + + + ); +}; + +export type InlineCitationCarouselContentProps = ComponentProps<"div">; + +export const InlineCitationCarouselContent = ( + props: InlineCitationCarouselContentProps, +) => ; + +export type InlineCitationCarouselItemProps = ComponentProps<"div">; + +export const InlineCitationCarouselItem = ({ + className, + ...props +}: InlineCitationCarouselItemProps) => ( + +); + +export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; + +export const InlineCitationCarouselHeader = ({ + className, + ...props +}: InlineCitationCarouselHeaderProps) => ( +
+); + +export type InlineCitationCarouselIndexProps = ComponentProps<"div">; + +export const InlineCitationCarouselIndex = ({ + children, + className, + ...props +}: InlineCitationCarouselIndexProps) => { + const api = useCarouselApi(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!api) { + return; + } + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + api.on("select", () => { + setCurrent(api.selectedScrollSnap() + 1); + }); + }, [api]); + + return ( +
+ {children ?? `${current}/${count}`} +
+ ); +}; + +export type InlineCitationCarouselPrevProps = ComponentProps<"button">; + +export const InlineCitationCarouselPrev = ({ + className, + ...props +}: InlineCitationCarouselPrevProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollPrev(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationCarouselNextProps = ComponentProps<"button">; + +export const InlineCitationCarouselNext = ({ + className, + ...props +}: InlineCitationCarouselNextProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollNext(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationSourceProps = ComponentProps<"div"> & { + title?: string; + url?: string; + description?: string; +}; + +export const InlineCitationSource = ({ + title, + url, + description, + className, + children, + ...props +}: InlineCitationSourceProps) => ( +
+ {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {description && ( +

+ {description} +

+ )} + {children} +
+); + +export type InlineCitationQuoteProps = ComponentProps<"blockquote">; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); diff --git a/apps/web/components/ai-elements/loader.tsx b/apps/web/components/ai-elements/loader.tsx new file mode 100644 index 0000000..37df01b --- /dev/null +++ b/apps/web/components/ai-elements/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from "@/lib/utils"; +import type { HTMLAttributes } from "react"; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/apps/web/components/ai-elements/message.tsx b/apps/web/components/ai-elements/message.tsx index 68b9e2e..f1ea0de 100644 --- a/apps/web/components/ai-elements/message.tsx +++ b/apps/web/components/ai-elements/message.tsx @@ -1,7 +1,9 @@ +/* eslint-disable @next/next/no-img-element */ "use client"; import { Button } from "@/components/ui/button"; import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, @@ -16,7 +18,6 @@ import { PaperclipIcon, XIcon, } from "lucide-react"; -import Image from "next/image"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react"; import { Streamdown } from "streamdown"; @@ -340,24 +341,54 @@ export function MessageAttachment({ data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; const isImage = mediaType === "image"; const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + const [isDialogOpen, setIsDialogOpen] = useState(false); return (
{isImage ? ( <> - {filename + + + + + +
+ {filename +
+
+
{onRemove && ( + )} + +); + +export type OpenInChatGPTProps = ComponentProps; + +export const OpenInChatGPT = (props: OpenInChatGPTProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.chatgpt.icon} + {providers.chatgpt.title} + + + + ); +}; + +export type OpenInClaudeProps = ComponentProps; + +export const OpenInClaude = (props: OpenInClaudeProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.claude.icon} + {providers.claude.title} + + + + ); +}; + +export type OpenInT3Props = ComponentProps; + +export const OpenInT3 = (props: OpenInT3Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.t3.icon} + {providers.t3.title} + + + + ); +}; + +export type OpenInSciraProps = ComponentProps; + +export const OpenInScira = (props: OpenInSciraProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.scira.icon} + {providers.scira.title} + + + + ); +}; + +export type OpenInv0Props = ComponentProps; + +export const OpenInv0 = (props: OpenInv0Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.v0.icon} + {providers.v0.title} + + + + ); +}; + +export type OpenInCursorProps = ComponentProps; + +export const OpenInCursor = (props: OpenInCursorProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.cursor.icon} + {providers.cursor.title} + + + + ); +}; diff --git a/apps/web/components/ai-elements/panel.tsx b/apps/web/components/ai-elements/panel.tsx new file mode 100644 index 0000000..056d9aa --- /dev/null +++ b/apps/web/components/ai-elements/panel.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import { Panel as PanelPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +type PanelProps = ComponentProps; + +export const Panel = ({ className, ...props }: PanelProps) => ( + +); diff --git a/apps/web/components/ai-elements/plan.tsx b/apps/web/components/ai-elements/plan.tsx new file mode 100644 index 0000000..be04d88 --- /dev/null +++ b/apps/web/components/ai-elements/plan.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { ChevronsUpDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { createContext, useContext } from "react"; +import { Shimmer } from "./shimmer"; + +type PlanContextValue = { + isStreaming: boolean; +}; + +const PlanContext = createContext(null); + +const usePlan = () => { + const context = useContext(PlanContext); + if (!context) { + throw new Error("Plan components must be used within Plan"); + } + return context; +}; + +export type PlanProps = ComponentProps & { + isStreaming?: boolean; +}; + +export const Plan = ({ + className, + isStreaming = false, + children, + ...props +}: PlanProps) => ( + + + {children} + + +); + +export type PlanHeaderProps = ComponentProps; + +export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => ( + +); + +export type PlanTitleProps = Omit< + ComponentProps, + "children" +> & { + children: string; +}; + +export const PlanTitle = ({ children, ...props }: PlanTitleProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanDescriptionProps = Omit< + ComponentProps, + "children" +> & { + children: string; +}; + +export const PlanDescription = ({ + className, + children, + ...props +}: PlanDescriptionProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanActionProps = ComponentProps; + +export const PlanAction = (props: PlanActionProps) => ( + +); + +export type PlanContentProps = ComponentProps; + +export const PlanContent = (props: PlanContentProps) => ( + + + +); + +export type PlanFooterProps = ComponentProps<"div">; + +export const PlanFooter = (props: PlanFooterProps) => ( + +); + +export type PlanTriggerProps = ComponentProps; + +export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => ( + + + +); diff --git a/apps/web/components/ai-elements/prompt-input.tsx b/apps/web/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000..7a95731 --- /dev/null +++ b/apps/web/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1414 @@ +/* eslint-disable @next/next/no-img-element */ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { ChatStatus, FileUIPart } from "ai"; +import { + CornerDownLeftIcon, + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import { + type ChangeEvent, + type ChangeEventHandler, + Children, + type ClipboardEventHandler, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type PropsWithChildren, + type ReactNode, + type RefObject, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +export type TextInputContext = { + value: string; + setInput: (v: string) => void; + clear: () => void; +}; + +export type PromptInputControllerProps = { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void, + ) => void; +}; + +const PromptInputController = createContext( + null, +); +const ProviderAttachmentsContext = createContext( + null, +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController().", + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments().", + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export function PromptInputProvider({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachmentFiles, setAttachmentFiles] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = Array.from(files); + if (incoming.length === 0) { + return; + } + + setAttachmentFiles((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })), + ), + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachmentFiles((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachmentFiles((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + // Keep a ref to attachments for cleanup on unmount (avoids stale closure) + const attachmentsRef = useRef(attachmentFiles); + attachmentsRef.current = attachmentFiles; + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + for (const f of attachmentsRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }; + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachmentFiles, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachmentFiles, add, remove, clear, openFileDialog], + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [], + ); + + const controller = useMemo( + () => ({ + textInput: { + value: textInput, + setInput: setTextInput, + clear: clearInput, + }, + attachments, + __registerFileInput, + }), + [textInput, clearInput, attachments, __registerFileInput], + ); + + return ( + + + {children} + + + ); +} + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Dual-mode: prefer provider if present, otherwise use local + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = provider ?? local; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider", + ); + } + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ""; + + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? "Image" : "Attachment")} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + "children" +> & { + children: (attachment: FileUIPart & { id: string }) => ReactNode; +}; + +export function PromptInputAttachments({ + children, + className, + ...props +}: PromptInputAttachmentsProps) { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return ( +
+ {attachments.files.map((file) => ( + {children(file)} + ))} +
+ ); +} + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + return ( + { + e.preventDefault(); + attachments.openFileDialog(); + }} + > + {label} + + ); +}; + +export type PromptInputMessage = { + text: string; + files: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent, + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const formRef = useRef(null); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + filesRef.current = files; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + + const patterns = accept + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + return patterns.some((pattern) => { + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ + return f.type.startsWith(prefix); + } + return f.type === pattern; + }); + }, + [accept], + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError], + ); + + const removeLocal = useCallback( + (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [], + ); + + const clearLocal = useCallback( + () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [], + ); + + const add = usingProvider ? controller.attachments.add : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const clear = usingProvider ? controller.attachments.clear : clearLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + if (globalDrop) return; // when global drop is on, let the document-level handler own drops + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + + [usingProvider], + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ""; + }; + + const convertBlobUrlToDataUrl = async ( + url: string, + ): Promise => { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + }; + + const ctx = useMemo( + () => ({ + files: files.map((item) => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [files, add, remove, clear, openFileDialog], + ); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + // Convert blob URLs to data URLs asynchronously + Promise.all( + files.map(async ({ ...item }) => { + if (item.url && item.url.startsWith("blob:")) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }), + ) + .then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + }; + + // Render with or without local provider + const inner = ( + <> + +
+ {children} +
+ + ); + + return usingProvider ? ( + inner + ) : ( + + {inner} + + ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ + className, + ...props +}: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps< + typeof InputGroupTextarea +>; + +export const PromptInputTextarea = ({ + onChange, + className, + placeholder = "What would you like to know?", + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = (e) => { + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const form = e.currentTarget.form; + const submitButton = form?.querySelector( + 'button[type="submit"]', + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === "Backspace" && + e.currentTarget.value === "" && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }; + + const handlePaste: ClipboardEventHandler = (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }; + + const controlledProps = controller + ? { + value: controller.textInput.value, + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + } + : { + onChange, + }; + + return ( + setIsComposing(false)} + onCompositionStart={() => setIsComposing(true)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder} + {...props} + {...controlledProps} + /> + ); +}; + +export type PromptInputHeaderProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputHeader = ({ + className, + ...props +}: PromptInputHeaderProps) => ( + +); + +export type PromptInputFooterProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputFooter = ({ + className, + ...props +}: PromptInputFooterProps) => ( + +); + +export type PromptInputToolsProps = HTMLAttributes; + +export const PromptInputTools = ({ + className, + ...props +}: PromptInputToolsProps) => ( +
+); + +export type PromptInputButtonProps = ComponentProps; + +export const PromptInputButton = ({ + variant = "ghost", + className, + size, + ...props +}: PromptInputButtonProps) => { + const newSize = + size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + + return ( + + ); +}; + +export type PromptInputActionMenuProps = ComponentProps; +export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( + +); + +export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; + +export const PromptInputActionMenuTrigger = ({ + className, + children, + ...props +}: PromptInputActionMenuTriggerProps) => ( + + + {children ?? } + + +); + +export type PromptInputActionMenuContentProps = ComponentProps< + typeof DropdownMenuContent +>; +export const PromptInputActionMenuContent = ({ + className, + ...props +}: PromptInputActionMenuContentProps) => ( + +); + +export type PromptInputActionMenuItemProps = ComponentProps< + typeof DropdownMenuItem +>; +export const PromptInputActionMenuItem = ({ + className, + ...props +}: PromptInputActionMenuItemProps) => ( + +); + +// Note: Actions that perform side-effects (like opening a file dialog) +// are provided in opt-in modules (e.g., prompt-input-attachments). + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; +}; + +export const PromptInputSubmit = ({ + className, + variant = "default", + size = "icon-sm", + status, + children, + ...props +}: PromptInputSubmitProps) => { + let Icon = ; + + if (status === "submitted") { + Icon = ; + } else if (status === "streaming") { + Icon = ; + } else if (status === "error") { + Icon = ; + } + + return ( + + {children ?? Icon} + + ); +}; + +interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + onstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onend: ((this: SpeechRecognition, ev: Event) => void) | null; + onresult: + | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) + | null; + onerror: + | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) + | null; +} + +interface SpeechRecognitionEvent extends Event { + results: SpeechRecognitionResultList; + resultIndex: number; +} + +type SpeechRecognitionResultList = { + readonly length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; +}; + +type SpeechRecognitionResult = { + readonly length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; + isFinal: boolean; +}; + +type SpeechRecognitionAlternative = { + transcript: string; + confidence: number; +}; + +interface SpeechRecognitionErrorEvent extends Event { + error: string; +} + +declare global { + interface Window { + SpeechRecognition: { + new (): SpeechRecognition; + }; + webkitSpeechRecognition: { + new (): SpeechRecognition; + }; + } +} + +export type PromptInputSpeechButtonProps = ComponentProps< + typeof PromptInputButton +> & { + textareaRef?: RefObject; + onTranscriptionChange?: (text: string) => void; +}; + +export const PromptInputSpeechButton = ({ + className, + textareaRef, + onTranscriptionChange, + ...props +}: PromptInputSpeechButtonProps) => { + const [isListening, setIsListening] = useState(false); + const [recognition, setRecognition] = useState( + null, + ); + const recognitionRef = useRef(null); + + useEffect(() => { + if ( + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) + ) { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; + const speechRecognition = new SpeechRecognition(); + + speechRecognition.continuous = true; + speechRecognition.interimResults = true; + speechRecognition.lang = "en-US"; + + speechRecognition.onstart = () => { + setIsListening(true); + }; + + speechRecognition.onend = () => { + setIsListening(false); + }; + + speechRecognition.onresult = (event) => { + let finalTranscript = ""; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result?.isFinal) { + finalTranscript += result[0]?.transcript ?? ""; + } + } + + if (finalTranscript && textareaRef?.current) { + const textarea = textareaRef.current; + const currentValue = textarea.value; + const newValue = + currentValue + (currentValue ? " " : "") + finalTranscript; + + textarea.value = newValue; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + onTranscriptionChange?.(newValue); + } + }; + + speechRecognition.onerror = (event) => { + console.error("Speech recognition error:", event.error); + setIsListening(false); + }; + + recognitionRef.current = speechRecognition; + setRecognition(speechRecognition); + } + + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, [textareaRef, onTranscriptionChange]); + + const toggleListening = useCallback(() => { + if (!recognition) { + return; + } + + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [recognition, isListening]); + + return ( + + + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => ( + + ); +}; + +export type WebPreviewBodyProps = ComponentProps<"iframe"> & { + loading?: ReactNode; +}; + +export const WebPreviewBody = ({ + className, + loading, + src, + ...props +}: WebPreviewBodyProps) => { + const { url } = useWebPreview(); + + return ( +
+