added ai custom dashboards

This commit is contained in:
aadesh18 2026-02-12 12:44:36 -08:00 committed by Konstantin Wohlwend
parent 3cde6faaa8
commit a2033bcaec
11 changed files with 1090 additions and 34 deletions

View File

@ -34,3 +34,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Generated files
src/generated

View File

@ -4,11 +4,13 @@
"repository": "https://github.com/stack-auth/stack-auth",
"private": true,
"scripts": {
"clean": "rimraf .next && rimraf node_modules",
"clean": "rimraf .next && rimraf node_modules && rimraf src/generated",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -c development --",
"with-env:prod": "dotenv -c --",
"dev": "next dev --turbopack --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01",
"bundle-type-definitions": "tsx scripts/bundle-type-definitions.ts",
"prebuild": "pnpm run bundle-type-definitions",
"build": "next build",
"docker-build": "next build --experimental-build-mode compile",
"analyze-bundle": "next experimental-analyze",
@ -17,6 +19,7 @@
"lint": "eslint ."
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.41",
"@ai-sdk/openai": "^3.0.25",
"@ai-sdk/react": "^3.0.72",
"@assistant-ui/react": "^0.10.24",

View File

@ -0,0 +1,65 @@
import { writeFileSyncIfChanged } from '@stackframe/stack-shared/dist/utils/fs';
import { glob } from 'glob';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
type TypeDefinitionFile = {
path: string,
content: string,
};
async function main() {
console.log('[Bundle Type Definitions] Finding Stack SDK type definition files...');
const rootPath = path.resolve(process.cwd(), '../..');
const stackAppPath = path.join(rootPath, 'packages/template/src/lib/stack-app');
const outputPath = path.join(rootPath, 'apps/dashboard/src/generated/bundled-type-definitions.ts');
const files = await glob(`${stackAppPath}/**/*.ts`, {
ignore: [
`${stackAppPath}/**/implementations/**`,
`${stackAppPath}/**/utils/**`,
`${stackAppPath}/**/*.d.ts`,
`${stackAppPath}/**/global.css`,
],
});
console.log(`[Bundle Type Definitions] Found ${files.length} type definition files`);
const bundledFiles: TypeDefinitionFile[] = [];
for (const filePath of files) {
const relativePath = path.relative(stackAppPath, filePath);
const content = await fs.readFile(filePath, 'utf8');
bundledFiles.push({
path: relativePath,
content,
});
}
console.log('[Bundle Type Definitions] Generating bundled-type-definitions.ts...');
const output = `// This file is auto-generated by scripts/bundle-type-definitions.ts
// Do not edit manually - changes will be overwritten
// Last generated: ${new Date().toISOString()}
export type TypeDefinitionFile = {
path: string,
content: string,
};
export const BUNDLED_TYPE_DEFINITIONS: TypeDefinitionFile[] = ${JSON.stringify(bundledFiles, null, 2)};
`;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
writeFileSyncIfChanged(outputPath, output);
console.log(`[Bundle Type Definitions] Generated ${outputPath}`);
console.log(`[Bundle Type Definitions] Total size: ${(output.length / 1024).toFixed(2)} KB, ${bundledFiles.length} files bundled`);
}
main().catch((...args) => {
console.error('[Bundle Type Definitions] ERROR! Failed to bundle type definitions:', ...args);
process.exit(1);
});

View File

@ -0,0 +1,28 @@
import { generateDashboardRuntimeCodegen } from "@/lib/ai-dashboard/model";
import { stackServerApp } from "@/stack";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
const requestSchema = yupObject({
projectId: yupString().defined().nonEmpty(),
prompt: yupString().defined().nonEmpty(),
}).defined();
export async function POST(req: Request) {
const user = await stackServerApp.getUser({ or: "redirect" });
const payload = await requestSchema.validate(await req.json());
const projects = await user.listOwnedProjects();
const project = projects.find((p: { id: string }) => p.id === payload.projectId);
if (!project) {
throwErr("You do not own this project");
}
const runtimeCodegen = await generateDashboardRuntimeCodegen(payload.prompt);
return Response.json({
prompt: payload.prompt,
projectId: payload.projectId,
runtimeCodegen,
});
}

View File

@ -12,6 +12,7 @@ import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/
import Image from "next/image";
import React, { memo, useEffect, useMemo } from "react";
import { AIChatPreview } from "./commands/ask-ai";
import { CreateDashboardPreview } from "./commands/create-dashboard/create-dashboard-preview";
import { RunQueryPreview } from "./commands/run-query";
export type CmdKPreviewProps = {
@ -31,38 +32,6 @@ export type CmdKPreviewProps = {
pathname: string,
};
// Create Dashboard Preview Component - shows a TODO message for now
const CreateDashboardPreview = memo(function CreateDashboardPreview({
query,
}: CmdKPreviewProps) {
return (
<div className="flex flex-col h-full w-full items-center justify-center p-6">
<div className="flex flex-col items-center gap-4 max-w-md text-center">
<div className="w-16 h-16 rounded-2xl bg-cyan-500/10 flex items-center justify-center">
<LayoutIcon className="h-8 w-8 text-cyan-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground mb-2">Create Dashboard</h3>
<p className="text-sm text-muted-foreground mb-4">
Generate custom dashboards for your users.
</p>
</div>
<div className="w-full p-4 rounded-xl bg-cyan-500/5 border border-cyan-500/20">
<p className="text-xs text-cyan-600 dark:text-cyan-400 font-medium mb-2">Your query:</p>
<p className="text-sm text-foreground italic">&ldquo;{query}&rdquo;</p>
</div>
<div className="mt-4 p-4 rounded-xl bg-muted/50 border border-border">
<p className="text-xs text-muted-foreground">
🚧 <span className="font-medium">Coming Soon</span> This feature is under development.
Soon you&apos;ll be able to create custom dashboards like &ldquo;analytics overview&rdquo;,
&ldquo;user management panel&rdquo;, or &ldquo;team activity feed&rdquo;.
</p>
</div>
</div>
</div>
);
});
// Available App Preview Component - shows app store page in preview panel
const AvailableAppPreview = memo(function AvailableAppPreview({
appId,

View File

@ -0,0 +1,122 @@
"use client";
import { Button } from "@/components/ui";
import { useDebouncedAction } from "@/hooks/use-debounced-action";
import {
CreateDashboardResponseSchema,
CreateDashboardResponse,
} from "@/lib/ai-dashboard/contracts";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { memo, useCallback, useMemo, useState } from "react";
import { CmdKPreviewProps } from "../../cmdk-commands";
import { DashboardSandboxHost } from "./dashboard-sandbox-host";
import { useProjectId } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
type GenerationState = "idle" | "generating" | "ready" | "error";
export function CreateDashboardPreview({ query, ...rest }: CmdKPreviewProps) {
return <CreateDashboardPreviewInner key={query} query={query} {...rest} />;
}
const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({
query,
}: CmdKPreviewProps) {
const projectId = useProjectId();
const prompt = query.trim();
const [state, setState] = useState<GenerationState>("idle");
const [errorText, setErrorText] = useState<string | null>(null);
const [artifact, setArtifact] = useState<CreateDashboardResponse | null>(null);
const generateDashboard = useCallback(async () => {
if (!projectId || !prompt) {
return;
}
setState("generating");
setErrorText(null);
setArtifact(null);
const response = await fetch("/api/create-dashboard", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
projectId,
prompt,
}),
});
if (!response.ok) {
const responseText = await response.text();
setState("error");
setErrorText(responseText || `Request failed with status ${response.status}`);
return;
}
const json = await response.json();
const parsed = CreateDashboardResponseSchema.safeParse(json);
if (!parsed.success) {
setState("error");
setErrorText(`Failed to parse generation response: ${parsed.error.issues[0]?.message ?? "Unknown error"}`);
return;
}
setArtifact(parsed.data);
setState("ready");
}, [projectId, prompt]);
useDebouncedAction({
action: generateDashboard,
delayMs: 500,
skip: !projectId || !prompt,
});
if (!prompt) {
return (
<div className="flex flex-col h-full w-full items-center justify-center p-6 text-center">
<h3 className="text-base font-semibold text-foreground">Create Dashboard</h3>
<p className="text-xs text-muted-foreground mt-1">Describe the dashboard you want and we will generate it in a sandbox.</p>
</div>
);
}
return (
<div className="flex h-full w-full flex-col">
<div className="px-3 py-2 border-b border-foreground/[0.08] space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[12px] font-medium text-foreground">Create Dashboard</div>
<div className="text-[10px] text-muted-foreground truncate">{prompt}</div>
</div>
<Button
size="sm"
variant="secondary"
disabled={state === "generating"}
onClick={async () => {
await generateDashboard();
}}
>
{state === "generating" ? "Generating..." : "Regenerate"}
</Button>
</div>
{state === "error" && errorText && (
<div className={cn("rounded-md border px-2 py-1.5 text-[10px]", "border-red-500/30 bg-red-500/10 text-red-200")}>
{errorText}
</div>
)}
</div>
<div className="flex-1 min-h-0 p-2">
{state === "generating" && (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">Generating dashboard...</div>
)}
{state !== "generating" && artifact && (
<DashboardSandboxHost artifact={artifact} />
)}
{state !== "generating" && !artifact && state !== "error" && (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">Waiting for generation...</div>
)}
</div>
</div>
);
});

View File

@ -0,0 +1,497 @@
"use client";
import { DashboardRuntimeCodegen } from "@/lib/ai-dashboard/contracts";
import { getPublicEnvVar } from "@/lib/env";
import { useUser } from "@stackframe/stack";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { memo, useEffect, useMemo, useRef, useState } from "react";
type DashboardArtifact = {
prompt: string,
projectId: string,
runtimeCodegen: DashboardRuntimeCodegen,
};
function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string): string {
const sourceCode = artifact.runtimeCodegen.uiRuntimeSourceCode;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdn.jsdelivr.net https://cdn.tailwindcss.com https://esm.sh https://js.stripe.com; style-src 'unsafe-inline' https://cdn.jsdelivr.net; img-src data:; connect-src ${baseUrl} https://unpkg.com https://cdn.jsdelivr.net https://esm.sh https://api.stripe.com https://m.stripe.com https://m.stripe.network; font-src 'none'; frame-src https://js.stripe.com https://hooks.stripe.com https://m.stripe.network; worker-src 'none';" />
<!-- Tailwind CSS Play CDN (for on-the-fly processing) -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
border: 'hsl(240 3.7% 15.9%)',
input: 'hsl(240 3.7% 15.9%)',
ring: 'hsl(240 4.9% 83.9%)',
background: 'hsl(240 10% 3.9%)',
foreground: 'hsl(0 0% 98%)',
primary: {
DEFAULT: 'hsl(0 0% 98%)',
foreground: 'hsl(240 5.9% 10%)',
},
secondary: {
DEFAULT: 'hsl(240 3.7% 15.9%)',
foreground: 'hsl(0 0% 98%)',
},
destructive: {
DEFAULT: 'hsl(0 62.8% 30.6%)',
foreground: 'hsl(0 0% 98%)',
},
muted: {
DEFAULT: 'hsl(240 3.7% 15.9%)',
foreground: 'hsl(240 5% 64.9%)',
},
accent: {
DEFAULT: 'hsl(240 3.7% 15.9%)',
foreground: 'hsl(0 0% 98%)',
},
card: {
DEFAULT: 'hsl(240 10% 3.9%)',
foreground: 'hsl(0 0% 98%)',
},
},
}
}
}
</script>
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow-x: hidden; font-family: Inter, system-ui, -apple-system, Segoe UI, sans-serif; background: #0b0b0f; color: #f3f4f6; }
#root { width: 100%; height: 100%; overflow-x: hidden; }
* { box-sizing: border-box; }
</style>
</head>
<body>
<div id="root"></div>
<!-- React, ReactDOM, and Babel -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script crossorigin src="https://unpkg.com/prop-types@15.8.1/prop-types.min.js"></script>
<script crossorigin src="https://unpkg.com/react-is@18/umd/react-is.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Recharts (via CDN) -->
<script src="https://cdn.jsdelivr.net/npm/recharts@2.15.4/umd/Recharts.js"></script>
<!-- Stack SDK (via esm.sh CDN) -->
<script type="module">
import * as StackSDK from 'https://esm.sh/@stackframe/stack@2.8.67';
// Expose Stack SDK globally for the Babel-transpiled code
window.StackAdminApp = StackSDK.StackAdminApp;
window.StackServerApp = StackSDK.StackServerApp;
window.StackSDK = StackSDK;
// Signal that SDK is loaded
window.__stackSdkReady = true;
window.dispatchEvent(new Event('stack-sdk-ready'));
</script>
<!-- UUID utility (via esm.sh CDN) -->
<script type="module">
import { generateUuid } from 'https://esm.sh/@stackframe/stack-shared@2.8.67/dist/utils/uuids';
window.generateUuid = generateUuid;
</script>
<script type="text/babel">
// Stack Server App config (no embedded token - fetched via postMessage)
const STACK_CONFIG = {
baseUrl: ${JSON.stringify(baseUrl)},
projectId: ${JSON.stringify(artifact.projectId)},
};
const Recharts = window.Recharts;
if (!Recharts) {
throw new Error("Recharts failed to load in sandbox. Check CDN dependencies.");
}
async function requestAccessToken() {
return new Promise((resolve, reject) => {
const requestId = window.generateUuid();
const timeout = setTimeout(() => {
window.removeEventListener('message', handler);
reject(new Error('Token request timeout'));
}, 5000);
const handler = (event) => {
if (event.data?.type === 'stack-access-token-response' && event.data?.requestId === requestId) {
clearTimeout(timeout);
window.removeEventListener('message', handler);
if (event.data.accessToken) {
resolve(event.data.accessToken);
} else {
reject(new Error('No access token received from parent'));
}
}
};
window.addEventListener('message', handler);
window.parent.postMessage({
type: 'stack-access-token-request',
requestId
}, '*');
});
}
async function initializeStackApp() {
if (!window.__stackSdkReady) {
await new Promise(resolve => {
window.addEventListener('stack-sdk-ready', resolve, { once: true });
});
}
if (!window.StackAdminApp) {
throw new Error("Stack SDK failed to load. The SDK should expose window.StackAdminApp.");
}
const stackServerApp = new window.StackAdminApp({
projectId: STACK_CONFIG.projectId,
baseUrl: STACK_CONFIG.baseUrl,
projectOwnerSession: async () => {
return await requestAccessToken();
},
});
// Make it globally available for AI-generated code
// Note: Variable name remains stackServerApp for compatibility, but it's a StackAdminApp instance
window.stackServerApp = stackServerApp;
return stackServerApp;
}
// Shadcn-style components
const Card = ({ children, className = "", ...props }) => (
<div className={\`rounded-lg border bg-card text-card-foreground shadow-sm \${className}\`} {...props}>
{children}
</div>
);
const CardHeader = ({ children, className = "", ...props }) => (
<div className={\`flex flex-col space-y-1.5 p-6 \${className}\`} {...props}>
{children}
</div>
);
const CardTitle = ({ children, className = "", ...props }) => (
<h3 className={\`text-2xl font-semibold leading-none tracking-tight \${className}\`} {...props}>
{children}
</h3>
);
const CardDescription = ({ children, className = "", ...props }) => (
<p className={\`text-sm text-muted-foreground \${className}\`} {...props}>
{children}
</p>
);
const CardContent = ({ children, className = "", ...props }) => (
<div className={\`p-6 pt-0 \${className}\`} {...props}>
{children}
</div>
);
const CardFooter = ({ children, className = "", ...props }) => (
<div className={\`flex items-center p-6 pt-0 \${className}\`} {...props}>
{children}
</div>
);
const Button = ({ children, className = "", variant = "default", size = "default", ...props }) => {
const variants = {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
};
const sizes = {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
};
return (
<button
className={\`inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 \${variants[variant]} \${sizes[size]} \${className}\`}
{...props}
>
{children}
</button>
);
};
const Table = ({ children, className = "", ...props }) => (
<div className="relative w-full overflow-auto">
<table className={\`w-full caption-bottom text-sm \${className}\`} {...props}>
{children}
</table>
</div>
);
const TableHeader = ({ children, className = "", ...props }) => (
<thead className={\`[&_tr]:border-b \${className}\`} {...props}>
{children}
</thead>
);
const TableBody = ({ children, className = "", ...props }) => (
<tbody className={\`[&_tr:last-child]:border-0 \${className}\`} {...props}>
{children}
</tbody>
);
const TableRow = ({ children, className = "", ...props }) => (
<tr className={\`border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted \${className}\`} {...props}>
{children}
</tr>
);
const TableHead = ({ children, className = "", ...props }) => (
<th className={\`h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 \${className}\`} {...props}>
{children}
</th>
);
const TableCell = ({ children, className = "", ...props }) => (
<td className={\`p-4 align-middle [&:has([role=checkbox])]:pr-0 \${className}\`} {...props}>
{children}
</td>
);
const Badge = ({ children, className = "", variant = "default", ...props }) => {
const variants = {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
};
return (
<div className={\`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 \${variants[variant]} \${className}\`} {...props}>
{children}
</div>
);
};
const Skeleton = ({ className = "", ...props }) => (
<div className={\`animate-pulse rounded-md bg-muted \${className}\`} {...props} />
);
const Separator = ({ className = "", orientation = "horizontal", ...props }) => (
<div
className={\`shrink-0 bg-border \${orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]"} \${className}\`}
{...props}
/>
);
// Error Boundary Component
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="p-6 text-red-500">
<h2 className="text-xl font-bold mb-2">Dashboard Error</h2>
<pre className="text-sm bg-red-950/20 p-4 rounded overflow-auto">
{this.state.error?.message || 'Unknown error'}
</pre>
{this.state.error?.stack && (
<pre className="text-xs bg-red-950/10 p-4 rounded overflow-auto mt-2">
{this.state.error.stack}
</pre>
)}
</div>
);
}
return this.props.children;
}
}
// AI-generated code will be inserted here
${sourceCode}
// Boot the dashboard
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
const root = ReactDOM.createRoot(rootElement);
// Initialize Stack SDK and boot the dashboard
initializeStackApp().then(() => {
try {
// Dashboard should be defined by the AI-generated code
if (typeof Dashboard !== 'function') {
throw new Error('Dashboard component not found in generated code');
}
root.render(
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
);
// Notify parent that sandbox is ready
parent.postMessage({ type: "stack-ai-dashboard-ready" }, "*");
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown sandbox error";
parent.postMessage({
type: "stack-ai-dashboard-error",
message: message,
stack: error instanceof Error ? error.stack : undefined,
}, "*");
// Render error in UI
root.render(
<div className="p-6 text-red-500">
<h2 className="text-xl font-bold mb-2">Failed to load dashboard</h2>
<pre className="text-sm bg-red-950/20 p-4 rounded">
{message}
</pre>
</div>
);
}
}).catch(error => {
const message = error instanceof Error ? error.message : "Failed to initialize Stack SDK";
parent.postMessage({
type: "stack-ai-dashboard-error",
message: message,
stack: error instanceof Error ? error.stack : undefined,
}, "*");
root.render(
<div className="p-6 text-red-500">
<h2 className="text-xl font-bold mb-2">Failed to initialize SDK</h2>
<pre className="text-sm bg-red-950/20 p-4 rounded">
{message}
</pre>
</div>
);
});
</script>
</body>
</html>`;
}
export const DashboardSandboxHost = memo(function DashboardSandboxHost({
artifact,
}: {
artifact: DashboardArtifact,
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [sandboxMessage, setSandboxMessage] = useState<string>("Waiting for sandbox...");
const user = useUser({ or: "redirect" });
// Get base URL from environment (same as used by stackServerApp)
const baseUrl = useMemo(() => {
return getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? 'http://localhost:8102';
}, []);
const srcDoc = useMemo(() => getSandboxDocument(artifact, baseUrl), [artifact, baseUrl]);
useEffect(() => {
setSandboxMessage("Waiting for sandbox...");
const timeoutId = setTimeout(() => {
setSandboxMessage("Sandbox loading...");
}, 10000);
return () => clearTimeout(timeoutId);
}, [artifact.prompt]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (typeof event.data !== "object" || event.data === null) {
return;
}
if (iframeRef.current?.contentWindow && event.source !== iframeRef.current.contentWindow) {
return;
}
const type = event.data.type;
if (type === "stack-access-token-request") {
const requestId = event.data.requestId;
runAsynchronously(async () => {
try {
const accessToken = await user.getAccessToken();
if (!accessToken) {
event.source?.postMessage({
type: 'stack-access-token-response',
requestId,
accessToken: null,
}, { targetOrigin: '*' } as any);
return;
}
event.source?.postMessage({
type: 'stack-access-token-response',
requestId,
accessToken,
}, { targetOrigin: '*' } as any);
} catch (error) {
event.source?.postMessage({
type: 'stack-access-token-response',
requestId,
accessToken: null,
}, { targetOrigin: '*' } as any);
}
});
return;
}
// Handle sandbox ready/error messages
if (type === "stack-ai-dashboard-ready") {
setSandboxMessage("Sandbox ready");
return;
}
if (type === "stack-ai-dashboard-error") {
const message = typeof event.data.message === "string" ? event.data.message : "Unknown sandbox error";
setSandboxMessage(`Sandbox error: ${message}`);
return;
}
};
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
};
}, [user]);
return (
<div className="flex flex-col h-full w-full gap-2">
<div className="text-[10px] text-muted-foreground/70">{sandboxMessage}</div>
<iframe
ref={iframeRef}
title="AI Dashboard Preview"
sandbox="allow-scripts"
srcDoc={srcDoc}
className="h-full w-full rounded-lg border border-foreground/[0.08] bg-[#0b0b0f]"
/>
</div>
);
});

View File

@ -0,0 +1,29 @@
import { z } from "zod/v4";
// Schema for AI-generated dashboard code
export const DashboardRuntimeCodegenSchema = z.object({
title: z.string().min(1).max(120),
description: z.string().min(1).max(800),
uiRuntimeSourceCode: z.string().min(1),
});
// Envelope for AI model output
export const DashboardRuntimeCodegenEnvelopeSchema = z.object({
runtimeCodegen: DashboardRuntimeCodegenSchema,
});
// Schema for file selection step
export const FileSelectionResponseSchema = z.object({
selectedFiles: z.array(z.string()),
});
// Backend API response schema
export const CreateDashboardResponseSchema = z.object({
prompt: z.string().min(1),
projectId: z.string().min(1),
runtimeCodegen: DashboardRuntimeCodegenSchema,
});
// Type exports
export type DashboardRuntimeCodegen = z.infer<typeof DashboardRuntimeCodegenSchema>;
export type CreateDashboardResponse = z.infer<typeof CreateDashboardResponseSchema>;

View File

@ -0,0 +1,311 @@
import { BUNDLED_TYPE_DEFINITIONS } from "@/generated/bundled-type-definitions";
import { createAnthropic } from "@ai-sdk/anthropic";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { generateText, Output } from "ai";
import { DashboardRuntimeCodegen, DashboardRuntimeCodegenEnvelopeSchema, FileSelectionResponseSchema } from "./contracts";
const anthropic = createAnthropic({
apiKey: getEnvVariable("STACK_ANTHROPIC_API_KEY", "MISSING_ANTHROPIC_API_KEY"),
});
const RUNTIME_CODEGEN_SYSTEM_PROMPT = `
[IDENTITY]
You are an analytics dashboard generator. You answer the users question with a focused, minimal dashboard of metrics + charts.
Your output is used to render a real UI. Therefore: prioritize clarity, relevance, and visual explanation over text.
CRITICAL: API ACCESS METHOD (HARD RULE)
You MUST use the global stackServerApp instance (already initialized).
Authentication is handled automatically - the SDK fetches access tokens from the parent window as needed.
You MUST NOT create a new StackServerApp or StackAdminApp instance.
You MUST NOT use fetch() directly.
IMPORTANT: All Stack API calls are async and may fail. ALWAYS:
1. Wrap API calls in try-catch blocks
2. Set error state when calls fail
3. Show user-friendly error messages (not technical details)
4. Log errors to console for debugging: console.error('[Dashboard]', error)
Example:
try {
const users = await stackServerApp.listUsers({ includeAnonymous: true });
setData(users);
} catch (error) {
console.error('[Dashboard] Failed to load users:', error);
setError('Failed to load user data');
}
await stackServerApp.getProject() // Admin API
await stackServerApp.listInternalApiKeys() // Admin API
Violating this is a failure condition.
PRIMARY OBJECTIVE
Build a dashboard that directly answers THE USERS SPECIFIC QUESTION.
A generic analytics dashboard is wrong.
Every card, chart, and table must exist only because it helps answer the query.
RESPONSE FORMAT (HARD RULE)
Return JSON ONLY, matching the required schema.
Do not include markdown. Do not include prose outside the JSON.
DASHBOARD REQUIREMENTS (HARD RULES)
1) Read the users query carefully. Build ONLY what answers it.
2) The dashboard MUST include at least one Recharts chart that visualizes the answer.
- Text-only dashboards are not allowed.
3) Keep it concise:
- 24 metric cards
- 12 charts
- Optional: a small table ONLY if it adds decision-useful detail
4) Never show technical details in the UI:
- No API names, method names, SDK details, types, or implementation notes.
5) Use professional, clean design:
- Clear hierarchy, good spacing, good contrast, readable labels.
DEFAULT-TO-ACTION BEHAVIOR
By default, implement the dashboard (data fetch + transformation + UI) rather than suggesting ideas.
If the users intent is slightly ambiguous, infer the most useful dashboard and proceed.
RUNTIME CONTRACT (HARD RULES)
- Define a React functional component named "Dashboard" (no props)
- Use hooks via the React global object: React.useState, React.useEffect, React.useCallback
- All shadcn components are globally available (no imports)
- Recharts is available via the global Recharts object
- Use stackServerApp for all Stack API calls
No import/export/require statements. No external networking calls.
CORE DATA FETCHING RULES (STACK)
Users:
- stackServerApp.listUsers(options?)
- ALWAYS set includeAnonymous: true
- Prefer limit: 500 (or higher only if clearly necessary)
- Avoid pagination/cursor unless the UI explicitly needs it
- Result is an array that may contain .nextCursor; treat it as an array for normal usage
Teams:
- stackServerApp.listTeams(options?) Promise<ServerTeam[]>
Project:
- stackServerApp.getProject() Promise<Project>
Important:
- Use camelCase options (includeAnonymous)
- The SDK handles auth/retries/errors; still show graceful UI states
CHART RULES (RECHARTS REQUIRED)
- Every dashboard MUST include at least one chart.
- Choose chart types that match the question:
- Trends over time LineChart / AreaChart
- Comparisons/top-N BarChart
- Distributions PieChart (or BarChart if many categories)
- Always wrap charts in ResponsiveContainer.
- Use XAxis/YAxis + Tooltip; include CartesianGrid when useful.
- If the query is time-series, ALWAYS show a time-series chart.
Do not overwhelm: 12 charts maximum.
LAYOUT & DESIGN RULES (PRACTICAL)
Use this container baseline:
<div className="p-6 space-y-6 max-w-7xl mx-auto">
Header:
- Clear title that matches the question
- Optional Refresh button (disabled while loading)
Metrics:
- 24 cards, grid layout:
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
- Big numbers:
"text-4xl font-bold" (or "text-3xl font-bold")
- Keep titles short. Minimize CardDescription.
Tables (optional):
- Only include if it helps answer the question (e.g., top teams list, recent users)
- Keep it small and readable. Add row hover: "hover:bg-muted/50"
Loading & Errors:
- Always show Skeleton while loading
- Disable interactions during loading
- If an error happens, show a small, user-friendly message in the UI (non-technical)
- Still avoid technical details (no stack traces, no method names)
EXAMPLES (MENTAL MODEL, NOT UI TEXT)
Query: "how many users do I have?"
Total users card, verified card, anonymous card, signup trend chart
Query: "what users came from oauth providers?"
OAuth vs email cards, provider distribution chart (Google/GitHub/etc.)
Query: "show me user growth over time"
Total users card, net-new in period card, growth rate card, line chart
Query: "which teams have the most users?"
Total teams card, avg users per team card, bar chart of top teams
AVAILABLE SHADCN COMPONENTS (GLOBAL)
Card:
- <Card>, <CardHeader>, <CardTitle>, <CardDescription>, <CardContent>, <CardFooter>
Button:
- <Button variant="default|destructive|outline|secondary|ghost|link" size="default|sm|lg|icon">
Table:
- <Table>, <TableHeader>, <TableBody>, <TableRow>, <TableHead>, <TableCell>
Other:
- <Badge variant="default|secondary|destructive|outline">
- <Skeleton className="..." />
- <Separator orientation="horizontal|vertical" />
RECHARTS (GLOBAL)
Use via Recharts.*:
- Recharts.LineChart, Recharts.BarChart, Recharts.AreaChart, Recharts.PieChart
- Recharts.XAxis, Recharts.YAxis, Recharts.CartesianGrid, Recharts.Tooltip, Recharts.Legend
- Recharts.Line, Recharts.Bar, Recharts.Area, Recharts.ResponsiveContainer
IMPORTANT IMPLEMENTATION NOTES (HARD RULES)
- Always define: function Dashboard() { ... }
- Use React.useState / React.useEffect (no imports)
- No exports, no imports 
- No new StackServerApp(...)
- No fetch()
- No technical implementation text in the UI
- Keep it minimal: short titles, big numbers, clear visuals
TYPE DEFINITIONS
The typeDefinitions field contains TypeScript source defining Stack API shapes.
Use it to determine available fields.
Key: ServerUser.oauthProviders is readonly { id: string }[] with provider IDs like "google", "github".
CLICKHOUSE (queryAnalytics only)
Available tables:
events:
- event_type: LowCardinality(String) ($token-refresh only)
- event_at: DateTime64(3, 'UTC')
- data: JSON
- user_id: Nullable(String)
- team_id: Nullable(String)
- created_at: DateTime64(3, 'UTC')
users (limited fields):
- id: UUID
- display_name: Nullable(String)
- primary_email: Nullable(String)
- primary_email_verified: UInt8 (0/1)
- signed_up_at: DateTime64(3, 'UTC')
- client_metadata: JSON
- client_read_only_metadata: JSON
- server_metadata: JSON
- is_anonymous: UInt8 (0/1)
GENERAL
- Keep code practical and deterministic
- Prefer robust null checks and safe date handling
- Write clean JSX with proper indentation
- Use semantic HTML and ARIA where appropriate
`;
async function selectRelevantFiles(prompt: string, availableFiles: string[]): Promise<string[]> {
const result = await generateText({
model: anthropic("claude-haiku-4-5"),
system: `You are a code assistant helping to generate dashboard code for Stack Auth.
Your task is to select which Stack SDK type definition files you'll need to generate the requested dashboard.
IMPORTANT GUIDELINES:
- DO NOT be conservative in file selection - when in doubt, INCLUDE the file
- If a file might be relevant to the dashboard, SELECT IT
- For user/team dashboards: select users and/or teams files
- For project info: select projects files
- Always select server-app.ts as it contains the main SDK interface
- It's better to include extra files than to miss necessary types
Available files:
${availableFiles.map(f => `- ${f}`).join('\n')}`,
prompt: `Dashboard request: "${prompt}"
Which type definition files do you need? When uncertain, err on the side of INCLUDING more files rather than fewer.`,
output: Output.object({
schema: FileSelectionResponseSchema,
}),
});
return result.output.selectedFiles;
}
function loadSelectedTypeDefinitions(selectedFiles: string[]): string {
const fileContents = selectedFiles.map((relativePath: string) => {
const file = BUNDLED_TYPE_DEFINITIONS.find((f: {path: string}) => f.path === relativePath);
if (!file) {
throw new Error(`Type definition file not found in bundle: ${relativePath}`);
}
return `
=== ${relativePath} ===
${file.content}
`;
});
return `
Complete Stack Auth SDK Type Definitions (Selected Files):
These files show the available methods, types, and interfaces for the Stack SDK.
${fileContents.join('\n')}
`.trim();
}
export async function generateDashboardRuntimeCodegen(prompt: string): Promise<DashboardRuntimeCodegen> {
// Step 1: Get available file paths from bundled definitions
const availableFiles = BUNDLED_TYPE_DEFINITIONS.map((f: {path: string}) => f.path);
// Step 2: Ask Claude which files it needs
const selectedFiles = await selectRelevantFiles(prompt, availableFiles);
// Step 3: Load only the selected files from bundle
const typeDefinitions = loadSelectedTypeDefinitions(selectedFiles);
// Step 4: Generate the dashboard with selected type definitions
const result = await generateText({
model: anthropic("claude-haiku-4-5"),
system: RUNTIME_CODEGEN_SYSTEM_PROMPT,
prompt: JSON.stringify({
prompt: prompt,
typeDefinitions,
}),
output: Output.object({
schema: DashboardRuntimeCodegenEnvelopeSchema,
}),
});
return result.output.runtimeCodegen;
}

View File

@ -340,6 +340,9 @@ importers:
apps/dashboard:
dependencies:
'@ai-sdk/anthropic':
specifier: ^3.0.41
version: 3.0.44(zod@4.1.12)
'@ai-sdk/openai':
specifier: ^3.0.25
version: 3.0.27(zod@4.1.12)
@ -2225,6 +2228,12 @@ importers:
packages:
'@ai-sdk/anthropic@3.0.44':
resolution: {integrity: sha512-ke1NldgohWJ7sWLqm9Um9TVIOrtg8Y8AecWeB6PgaLt+paTPisAsyNfe8FNOVusuv58ugLBqY/78AkhUmbjXHA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.41':
resolution: {integrity: sha512-dYNhtvEomccNNGSxfSP8f4g6yPcoDHyQ6Rb7dALFE0FvvVP9UqfFWi3D2dLIz0VVKaSkiNLQAJ7lsdTVlBdRrw==}
engines: {node: '>=18'}
@ -2255,6 +2264,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.15':
resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@1.1.3':
resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==}
engines: {node: '>=18'}
@ -16066,6 +16081,12 @@ packages:
snapshots:
'@ai-sdk/anthropic@3.0.44(zod@4.1.12)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@4.1.12)
zod: 4.1.12
'@ai-sdk/gateway@3.0.41(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
@ -16113,6 +16134,13 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.1.12
'@ai-sdk/provider-utils@4.0.15(zod@4.1.12)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 4.1.12
'@ai-sdk/provider@1.1.3':
dependencies:
json-schema: 0.4.0
@ -26627,7 +26655,7 @@ snapshots:
effect@3.18.4:
dependencies:
'@standard-schema/spec': 1.0.0
'@standard-schema/spec': 1.1.0
fast-check: 3.23.2
electron-to-chromium@1.4.803: {}

View File

@ -9,6 +9,7 @@ packages:
minimumReleaseAge: 2880
minimumReleaseAgeExclude:
- ai
- '@ai-sdk/anthropic'
- '@ai-sdk/openai'
- '@ai-sdk/react'
- '@ai-sdk/provider'