From 8e50734a2cadd889163db71f94f25680a306082f Mon Sep 17 00:00:00 2001 From: amelitoalcuitas Date: Sun, 17 May 2026 22:08:24 +0800 Subject: [PATCH 1/4] chore(playground): improve canvas generation UX --- .../src/app/api/canvas/generate/route.ts | 10 +- .../playground/canvas-page-client.tsx | 219 +++++++++++++++--- .../src/components/playground/chat-ui.tsx | 1 + apps/playground/src/lib/utils.ts | 69 ++++-- 4 files changed, 255 insertions(+), 44 deletions(-) diff --git a/apps/playground/src/app/api/canvas/generate/route.ts b/apps/playground/src/app/api/canvas/generate/route.ts index 963f192b7a..4b7bdd9894 100644 --- a/apps/playground/src/app/api/canvas/generate/route.ts +++ b/apps/playground/src/app/api/canvas/generate/route.ts @@ -11,6 +11,7 @@ export const maxDuration = 300; interface CanvasGenerateBody { prompt: string; model?: string; + apiKey?: string; } export async function POST(req: Request) { @@ -30,7 +31,7 @@ export async function POST(req: Request) { status: 400, }); } - const { prompt, model } = body; + const { prompt, model, apiKey } = body; const systemPrompt = catalog.prompt(); if (!prompt) { @@ -39,12 +40,15 @@ export async function POST(req: Request) { }); } + const headerApiKey = req.headers.get("x-llmgateway-key") ?? undefined; + const cookieStore = await cookies(); const cookieApiKey = cookieStore.get("llmgateway_playground_key")?.value ?? cookieStore.get("__Host-llmgateway_playground_key")?.value; + const finalApiKey = apiKey ?? headerApiKey ?? cookieApiKey; - if (!cookieApiKey) { + if (!finalApiKey) { return new Response(JSON.stringify({ error: "Missing API key" }), { status: 400, }); @@ -57,7 +61,7 @@ export async function POST(req: Request) { : "https://api.llmgateway.io/v1"); const llmgateway = createLLMGateway({ - apiKey: cookieApiKey, + apiKey: finalApiKey, baseURL: gatewayUrl, headers: { "x-source": "chat.llmgateway.io", diff --git a/apps/playground/src/components/playground/canvas-page-client.tsx b/apps/playground/src/components/playground/canvas-page-client.tsx index ac4625db72..c10f890d78 100644 --- a/apps/playground/src/components/playground/canvas-page-client.tsx +++ b/apps/playground/src/components/playground/canvas-page-client.tsx @@ -18,6 +18,7 @@ import { memo, type RefObject, useCallback, + useEffect, useMemo, useRef, useState, @@ -59,10 +60,12 @@ import { } from "@/components/ui/select"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { Skeleton } from "@/components/ui/skeleton"; import { useIsMobile } from "@/hooks/use-mobile"; import { useUser } from "@/hooks/useUser"; import { registry } from "@/lib/canvas/registry"; import { emptySpec, templates } from "@/lib/canvas/templates"; +import { getErrorMessage } from "@/lib/utils"; import type { ApiModel, ApiProvider } from "@/lib/fetch-models"; import type { Organization, Project } from "@/lib/types"; @@ -95,6 +98,88 @@ function isRenderableSpec(value: unknown): value is Spec { ); } +const CANVAS_MODEL_KEY = "llmgateway_model_canvas"; +const DEFAULT_CANVAS_MODEL = "anthropic/claude-sonnet-4-20250514"; + +function CanvasSpecSkeleton() { + return ( +
+ +
+ + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ ); +} + +function CanvasPreviewSkeleton() { + return ( +
+ + +
+ + + +
+ +
+ + +
+
+ ); +} + +async function getResponseErrorMessage(response: Response): Promise { + const fallback = `HTTP ${response.status}: ${response.statusText}`; + const text = await response.text().catch(() => ""); + + if (!text.trim()) { + return fallback; + } + + try { + const payload: unknown = JSON.parse(text); + const message = getErrorMessage(payload); + + return message === "An unknown error occurred" ? fallback : message; + } catch { + const message = getErrorMessage(text); + + return message === "An unknown error occurred" ? fallback : message; + } +} + interface CanvasPromptInputProps { isGenerating: boolean; onGenerate: (prompt: string) => void; @@ -151,10 +236,19 @@ export default function CanvasPageClient({ models, providers, selectedOrganization, + selectedProject, }: CanvasPageClientProps) { - const [selectedModel, setSelectedModel] = useState( - "anthropic/claude-sonnet-4-20250514", - ); + const [selectedModel, setSelectedModel] = useState(() => { + try { + const stored = localStorage.getItem(CANVAS_MODEL_KEY); + if (stored) { + return stored; + } + } catch { + // ignore private-mode / quota errors + } + return DEFAULT_CANVAS_MODEL; + }); const [spec, setSpec] = useState(emptySpec); const [editorValue, setEditorValue] = useState( @@ -166,6 +260,7 @@ export default function CanvasPageClient({ const [promptResetKey, setPromptResetKey] = useState(0); const promptRef = useRef(null); const [isGenerating, setIsGenerating] = useState(false); + const [hasStreamingSpec, setHasStreamingSpec] = useState(false); const [showEditor, setShowEditor] = useState(true); const [previewExpanded, _setPreviewExpanded] = useState(false); const [exporting, setExporting] = useState<"pdf" | "image" | null>(null); @@ -176,7 +271,46 @@ export default function CanvasPageClient({ const { user, isLoading: isUserLoading } = useUser(); const pathname = usePathname(); - const showAuthDialog = !isUserLoading && !user; + const isAuthenticated = !isUserLoading && !!user; + const showAuthDialog = !isAuthenticated && !isUserLoading && !user; + const ensuredProjectRef = useRef(null); + + useEffect(() => { + if (selectedModel) { + try { + localStorage.setItem(CANVAS_MODEL_KEY, selectedModel); + } catch { + // ignore private-mode / quota errors + } + } + }, [selectedModel]); + + useEffect(() => { + if (!isAuthenticated || !selectedProject) { + ensuredProjectRef.current = null; + return; + } + + const ensureKey = async () => { + if (!selectedOrganization) { + return; + } + if (ensuredProjectRef.current === selectedProject.id) { + return; + } + try { + await fetch("/api/ensure-playground-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: selectedProject.id }), + }); + ensuredProjectRef.current = selectedProject.id; + } catch { + // ignore for now + } + }; + void ensureKey(); + }, [isAuthenticated, selectedOrganization, selectedProject]); const handleEditorChange = useCallback( (e: React.ChangeEvent) => { @@ -236,6 +370,9 @@ export default function CanvasPageClient({ const controller = new AbortController(); setIsGenerating(true); + setParseError(null); + setSelectedTemplateName(""); + setHasStreamingSpec(false); abortRef.current = controller; try { @@ -250,7 +387,7 @@ export default function CanvasPageClient({ }); if (!response.ok) { - throw new Error(`Generation failed: ${response.statusText}`); + throw new Error(await getResponseErrorMessage(response)); } const reader = response.body?.getReader(); @@ -260,6 +397,7 @@ export default function CanvasPageClient({ const decoder = new TextDecoder(); let specStreamBuffer = ""; + let didResetForStream = false; while (true) { const { done, value } = await reader.read(); @@ -270,6 +408,13 @@ export default function CanvasPageClient({ specStreamBuffer += decoder.decode(value, { stream: true }); if (specStreamBuffer.trim()) { + if (!didResetForStream) { + setSpec(emptySpec); + didResetForStream = true; + } + setHasStreamingSpec(true); + setEditorValue(specStreamBuffer); + try { const compiled = compileSpecStream(specStreamBuffer) as unknown; if (isRenderableSpec(compiled)) { @@ -287,6 +432,7 @@ export default function CanvasPageClient({ const compiled = compileSpecStream(specStreamBuffer) as unknown; if (isRenderableSpec(compiled)) { applySpec(compiled); + setHasStreamingSpec(true); appliedFinalSpec = true; } } catch { @@ -298,6 +444,7 @@ export default function CanvasPageClient({ const parsed = JSON.parse(specStreamBuffer) as unknown; if (isRenderableSpec(parsed)) { applySpec(parsed); + setHasStreamingSpec(true); } else { setParseError("Failed to parse AI response as a valid spec"); } @@ -408,11 +555,13 @@ export default function CanvasPageClient({ () => JSON.stringify(spec.state ?? {}), [spec.state], ); + const showGenerationLoading = isGenerating && !hasStreamingSpec; const handleResetCanvas = useCallback(() => { setSpec(emptySpec); setEditorValue(JSON.stringify(emptySpec, null, 2)); setParseError(null); + setHasStreamingSpec(false); setSelectedTemplateName(""); setPromptResetKey((key) => key + 1); setShowResetDialog(false); @@ -530,13 +679,20 @@ export default function CanvasPageClient({ -